diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 16e28e11f..9ed89fa7f 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -28,6 +28,8 @@ import { import { initializeDatabase, getDatabase, + bootstrapCollections, + fromKeyValueStorage, } from '@perawallet/wallet-core-database' import { seedAlgoAsset } from '@perawallet/wallet-core-assets' import { initializeSyncService } from '@perawallet/wallet-core-background' @@ -98,7 +100,14 @@ const AppContent = () => { provider.keyValueStorage.setItem(APP_INSTALLED_KEY, '1') await initializeDatabase(provider.database) - await seedAlgoAsset(getDatabase()) + bootstrapCollections({ + mmkv: fromKeyValueStorage(provider.keyValueStorage), + }) + // Keep a reference to the legacy SQLite handle alive so + // domains that have not yet migrated to the collections + // layer continue to function. + getDatabase() + await seedAlgoAsset() initializeSyncService({ queryClient }) diff --git a/apps/mobile/src/modules/settings/hooks/__tests__/useDeleteAllData.test.ts b/apps/mobile/src/modules/settings/hooks/__tests__/useDeleteAllData.test.ts index 5cfc7fa28..2304676c7 100644 --- a/apps/mobile/src/modules/settings/hooks/__tests__/useDeleteAllData.test.ts +++ b/apps/mobile/src/modules/settings/hooks/__tests__/useDeleteAllData.test.ts @@ -31,6 +31,7 @@ const mockRemoveItem = vi.fn() const mockClearKeystore = vi.fn().mockResolvedValue(undefined) const mockDeleteDatabase = vi.fn().mockResolvedValue(undefined) const mockInitializeDatabase = vi.fn().mockResolvedValue(undefined) +const mockResetAllCollections = vi.fn() vi.mock('@perawallet/wallet-extension-provider', () => ({ clearDataStores: vi.fn(), @@ -44,6 +45,7 @@ vi.mock('@perawallet/wallet-extension-provider', () => ({ vi.mock('@perawallet/wallet-core-database', () => ({ deleteDatabase: (...args: unknown[]) => mockDeleteDatabase(...args), initializeDatabase: (...args: unknown[]) => mockInitializeDatabase(...args), + resetAllCollections: () => mockResetAllCollections(), })) vi.mock('@perawallet/wallet-core-shared', () => ({ diff --git a/apps/mobile/src/modules/settings/hooks/useDeleteAllData.ts b/apps/mobile/src/modules/settings/hooks/useDeleteAllData.ts index 85e1bfefb..3fcae13f6 100644 --- a/apps/mobile/src/modules/settings/hooks/useDeleteAllData.ts +++ b/apps/mobile/src/modules/settings/hooks/useDeleteAllData.ts @@ -15,6 +15,7 @@ import { useNetwork } from '@perawallet/wallet-core-blockchain' import { deleteDatabase, initializeDatabase, + resetAllCollections, } from '@perawallet/wallet-core-database' import { useDeleteDeviceMutation } from '@perawallet/wallet-core-device' import { useKMS } from '@perawallet/wallet-core-kms' @@ -101,6 +102,16 @@ export const useDeleteAllData = (): UseDeleteAllDataResult => { logger.error('Failed to delete database', { error: e }) } + // 8. Wipe every TanStack-DB-style collection backed by MMKV. + // Collections persist independently of the SQLite file, so + // `deleteDatabase` does not touch them — they need their own + // reset hook so the app boots into a fully empty state. + try { + resetAllCollections() + } catch (e) { + logger.error('Failed to reset collections', { error: e }) + } + clearAllStores({ skip: [ACCOUNTS_STORE_NAME] }) }, [ queryClient, diff --git a/packages/accounts/src/db/__tests__/repository.spec.ts b/packages/accounts/src/db/__tests__/repository.spec.ts index c4a365fbb..2c62bbd82 100644 --- a/packages/accounts/src/db/__tests__/repository.spec.ts +++ b/packages/accounts/src/db/__tests__/repository.spec.ts @@ -13,11 +13,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Decimal } from 'decimal.js' import { - runMigrations, - migrations, - type Database, + bootstrapTestCollections, + resetRegistryForTest, + type CollectionRegistry, } from '@perawallet/wallet-core-database' -import { createTestDatabase } from '@perawallet/wallet-core-database/test-utils' import { upsertAssets, PeraAssetType, @@ -34,35 +33,33 @@ import { getAllAssetIdsForNetwork, } from '../repository' +const dec = (n: number | string): Decimal => new Decimal(n) + describe('account repository', () => { - let db: Database - let teardown: () => void - - beforeEach(async () => { - const result = createTestDatabase() - db = result.db - teardown = result.teardown - await runMigrations(db, migrations) + let registry: CollectionRegistry + + beforeEach(() => { + registry = bootstrapTestCollections() }) afterEach(() => { - teardown() + resetRegistryForTest() }) describe('holdings', () => { it('inserts and retrieves holdings', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [ - { assetId: '100', amount: 5000n }, - { assetId: '200', amount: 300n }, + { assetId: '100', amount: dec(5000) }, + { assetId: '200', amount: dec(300) }, ], network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) @@ -73,50 +70,50 @@ describe('account repository', () => { it('replaces all holdings on upsert', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [ - { assetId: '100', amount: 5000n }, - { assetId: '200', amount: 300n }, + { assetId: '100', amount: dec(5000) }, + { assetId: '200', amount: dec(300) }, ], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '300', amount: 999n }], + holdings: [{ assetId: '300', amount: dec(999) }], network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) expect(result).toHaveLength(1) expect(result[0].assetId).toBe('300') - expect(result[0].amount).toEqual(new Decimal(999)) + expect(result[0].amount).toEqual(dec(999)) }) it('handles empty holdings', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 5000n }], + holdings: [{ assetId: '100', amount: dec(5000) }], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [], network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) @@ -126,48 +123,48 @@ describe('account repository', () => { it('isolates holdings by account and network', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 10n }], + holdings: [{ assetId: '100', amount: dec(10) }], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', - holdings: [{ assetId: '200', amount: 20n }], + holdings: [{ assetId: '200', amount: dec(20) }], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '300', amount: 30n }], + holdings: [{ assetId: '300', amount: dec(30) }], network: 'testnet', }) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }), ).toHaveLength(1) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', network: 'mainnet', }), ).toHaveLength(1) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'testnet', }), ).toHaveLength(1) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', network: 'testnet', }), @@ -176,7 +173,7 @@ describe('account repository', () => { it('returns empty array for unknown account', async () => { const result = await getAccountHoldings({ - db, + registry, accountAddress: 'UNKNOWN', network: 'mainnet', }) @@ -200,7 +197,7 @@ describe('account repository', () => { assetId, decimals: 0, creator: { address: 'CREATOR' }, - totalSupply: new Decimal(1), + totalSupply: dec(1), peraMetadata: { isDeleted: false, verificationTier: 'unverified', @@ -212,19 +209,19 @@ describe('account repository', () => { beforeEach(async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [ - { assetId: '100', amount: new Decimal(50) }, - { assetId: '200', amount: new Decimal(0) }, - { assetId: '300', amount: new Decimal(1) }, - { assetId: '400', amount: new Decimal(0) }, - { assetId: '500', amount: new Decimal(0) }, + { assetId: '100', amount: dec(50) }, + { assetId: '200', amount: dec(0) }, + { assetId: '300', amount: dec(1) }, + { assetId: '400', amount: dec(0) }, + { assetId: '500', amount: dec(0) }, ], network: 'mainnet', }) await upsertAssets({ - db, + registry, items: [ makeAsset('100', PeraAssetType.standard_asset), makeAsset('200', PeraAssetType.standard_asset), @@ -237,7 +234,7 @@ describe('account repository', () => { const idsOf = async (filters: Record) => { const rows = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', ...filters, @@ -265,7 +262,6 @@ describe('account repository', () => { }) it('hideOptedInNfts excludes only zero-balance collectibles', async () => { - // Owned NFT '300' is kept, opted-in '400' is dropped, unknown '500' kept. expect(await idsOf({ hideOptedInNfts: true })).toEqual([ '100', '200', @@ -281,8 +277,6 @@ describe('account repository', () => { }) it('combines hideZeroBalance with hideOptedInNfts', async () => { - // hideZeroBalance drops '200', '400', '500'; hideOptedInNfts is - // already covered by hideZeroBalance for collectibles. expect( await idsOf({ hideZeroBalance: true, @@ -303,65 +297,65 @@ describe('account repository', () => { describe('insertAssetHolding', () => { it('inserts a new holding with zero amount', async () => { await insertAssetHolding({ - db, + registry, accountAddress: 'ADDR1', assetId: '100', network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) expect(result).toHaveLength(1) expect(result[0].assetId).toBe('100') - expect(result[0].amount).toEqual(new Decimal(0)) + expect(result[0].amount).toEqual(dec(0)) }) it('does not overwrite existing holding on conflict', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 500n }], + holdings: [{ assetId: '100', amount: dec(500) }], network: 'mainnet', }) await insertAssetHolding({ - db, + registry, accountAddress: 'ADDR1', assetId: '100', network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) expect(result).toHaveLength(1) - expect(result[0].amount).toEqual(new Decimal(500)) + expect(result[0].amount).toEqual(dec(500)) }) it('adds alongside existing holdings', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 10n }], + holdings: [{ assetId: '100', amount: dec(10) }], network: 'mainnet', }) await insertAssetHolding({ - db, + registry, accountAddress: 'ADDR1', assetId: '200', network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) @@ -374,25 +368,25 @@ describe('account repository', () => { describe('deleteAssetHoldings', () => { it('deletes specified asset holdings', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [ - { assetId: '100', amount: 0n }, - { assetId: '200', amount: 0n }, - { assetId: '300', amount: 0n }, + { assetId: '100', amount: dec(0) }, + { assetId: '200', amount: dec(0) }, + { assetId: '300', amount: dec(0) }, ], network: 'mainnet', }) await deleteAssetHoldings({ - db, + registry, accountAddress: 'ADDR1', assetIds: ['100', '200'], network: 'mainnet', }) const result = await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) @@ -403,20 +397,20 @@ describe('account repository', () => { it('does not affect other accounts', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 0n }], + holdings: [{ assetId: '100', amount: dec(0) }], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', - holdings: [{ assetId: '100', amount: 0n }], + holdings: [{ assetId: '100', amount: dec(0) }], network: 'mainnet', }) await deleteAssetHoldings({ - db, + registry, accountAddress: 'ADDR1', assetIds: ['100'], network: 'mainnet', @@ -424,14 +418,14 @@ describe('account repository', () => { expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }), ).toHaveLength(0) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', network: 'mainnet', }), @@ -440,20 +434,20 @@ describe('account repository', () => { it('does not affect other networks', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 0n }], + holdings: [{ assetId: '100', amount: dec(0) }], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 0n }], + holdings: [{ assetId: '100', amount: dec(0) }], network: 'testnet', }) await deleteAssetHoldings({ - db, + registry, accountAddress: 'ADDR1', assetIds: ['100'], network: 'mainnet', @@ -461,14 +455,14 @@ describe('account repository', () => { expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }), ).toHaveLength(0) expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'testnet', }), @@ -477,14 +471,14 @@ describe('account repository', () => { it('handles empty assetIds array', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', - holdings: [{ assetId: '100', amount: 0n }], + holdings: [{ assetId: '100', amount: dec(0) }], network: 'mainnet', }) await deleteAssetHoldings({ - db, + registry, accountAddress: 'ADDR1', assetIds: [], network: 'mainnet', @@ -492,7 +486,7 @@ describe('account repository', () => { expect( await getAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }), @@ -503,75 +497,75 @@ describe('account repository', () => { describe('balances', () => { it('inserts a new balance', async () => { await upsertAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', - algoBalance: 5000000n, + algoBalance: dec(5000000), totalAssetsOptedIn: 3, totalCreatedAssets: 1, totalAppsOptedIn: 2, - minBalance: 100000n, + minBalance: dec(100000), status: 'Online', authAddress: null, }) const result = await getAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) expect(result).toBeDefined() - expect(result!.algoBalance).toEqual(new Decimal(5000000)) + expect(result!.algoBalance).toEqual(dec(5000000)) expect(result!.totalAssetsOptedIn).toBe(3) - expect(result!.minBalance).toEqual(new Decimal(100000)) + expect(result!.minBalance).toEqual(dec(100000)) expect(result!.status).toBe('Online') }) it('updates an existing balance on conflict', async () => { await upsertAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', - algoBalance: 5000000n, + algoBalance: dec(5000000), totalAssetsOptedIn: 3, totalCreatedAssets: 1, totalAppsOptedIn: 2, - minBalance: 100000n, + minBalance: dec(100000), status: 'Online', authAddress: null, }) await upsertAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', - algoBalance: 9000000n, + algoBalance: dec(9000000), totalAssetsOptedIn: 5, totalCreatedAssets: 2, totalAppsOptedIn: 3, - minBalance: 200000n, + minBalance: dec(200000), status: 'Offline', authAddress: 'AUTH1', }) const result = await getAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', }) expect(result).toBeDefined() - expect(result!.algoBalance).toEqual(new Decimal(9000000)) + expect(result!.algoBalance).toEqual(dec(9000000)) expect(result!.totalAssetsOptedIn).toBe(5) - expect(result!.minBalance).toEqual(new Decimal(200000)) + expect(result!.minBalance).toEqual(dec(200000)) expect(result!.status).toBe('Offline') expect(result!.authAddress).toBe('AUTH1') }) it('returns undefined for unknown account', async () => { const result = await getAccountBalance({ - db, + registry, accountAddress: 'UNKNOWN', network: 'mainnet', }) @@ -581,32 +575,32 @@ describe('account repository', () => { it('retrieves all balances for multiple addresses', async () => { await upsertAccountBalance({ - db, + registry, accountAddress: 'ADDR1', network: 'mainnet', - algoBalance: 1000n, + algoBalance: dec(1000), totalAssetsOptedIn: 0, totalCreatedAssets: 0, totalAppsOptedIn: 0, - minBalance: 0n, + minBalance: dec(0), status: 'Offline', authAddress: null, }) await upsertAccountBalance({ - db, + registry, accountAddress: 'ADDR2', network: 'mainnet', - algoBalance: 2000n, + algoBalance: dec(2000), totalAssetsOptedIn: 0, totalCreatedAssets: 0, totalAppsOptedIn: 0, - minBalance: 0n, + minBalance: dec(0), status: 'Offline', authAddress: null, }) const result = await getAllAccountBalances({ - db, + registry, accountAddresses: ['ADDR1', 'ADDR2'], network: 'mainnet', }) @@ -618,26 +612,26 @@ describe('account repository', () => { describe('getAllAssetIdsForNetwork', () => { it('returns distinct asset IDs across accounts', async () => { await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR1', holdings: [ - { assetId: '100', amount: 10n }, - { assetId: '200', amount: 20n }, + { assetId: '100', amount: dec(10) }, + { assetId: '200', amount: dec(20) }, ], network: 'mainnet', }) await refreshAccountHoldings({ - db, + registry, accountAddress: 'ADDR2', holdings: [ - { assetId: '200', amount: 30n }, - { assetId: '300', amount: 40n }, + { assetId: '200', amount: dec(30) }, + { assetId: '300', amount: dec(40) }, ], network: 'mainnet', }) const result = await getAllAssetIdsForNetwork({ - db, + registry, network: 'mainnet', }) diff --git a/packages/accounts/src/db/repository.ts b/packages/accounts/src/db/repository.ts index 8f7cb86a8..77a7774db 100644 --- a/packages/accounts/src/db/repository.ts +++ b/packages/accounts/src/db/repository.ts @@ -10,11 +10,29 @@ limitations under the License */ -import { eq, and, inArray, notInArray, ne, or, isNull } from 'drizzle-orm' import { Decimal } from 'decimal.js' -import { getDatabase, type Database } from '@perawallet/wallet-core-database' -import { AssetsPeraSchema, PeraAssetType } from '@perawallet/wallet-core-assets' -import { AccountAssetHoldingsSchema, AccountBalancesSchema } from './schema' +import { + accountAssetHoldingsKey, + accountAssetHoldingsPrefix, + accountBalancesKey, + assetsPeraKey, + getCollections, + type AccountBalanceCollectionRow, + type CollectionRegistry, +} from '@perawallet/wallet-core-database' +import { PeraAssetType } from '@perawallet/wallet-core-assets' + +type WithRegistry = { registry?: CollectionRegistry } + +function resolveRegistry( + registry: CollectionRegistry | undefined, +): CollectionRegistry { + return registry ?? getCollections() +} + +// --------------------------------------------------------------------------- +// Holdings +// --------------------------------------------------------------------------- export type HoldingRow = { assetId: string @@ -26,47 +44,52 @@ type UpsertHoldingInput = { amount: Decimal } -type UpsertAccountHoldingsParams = { - db?: Database +type UpsertAccountHoldingsParams = WithRegistry & { accountAddress: string holdings: UpsertHoldingInput[] network: string } +/** + * Replace every holding for one (network, account) with the supplied set. + * + * Wrapped in a single `transact` so subscribers see one atomic update + * rather than a flicker of "no holdings" followed by each new row. The + * adapter also gets `deleteMany`/`putMany` batching on commit. + */ export async function refreshAccountHoldings({ - db = getDatabase(), + registry, accountAddress, holdings, network, }: UpsertAccountHoldingsParams): Promise { + const { accountAssetHoldings } = resolveRegistry(registry) const now = Date.now() - await db - .delete(AccountAssetHoldingsSchema) - .where( - and( - eq(AccountAssetHoldingsSchema.accountAddress, accountAddress), - eq(AccountAssetHoldingsSchema.network, network), - ), - ) - .run() + const prefix = accountAssetHoldingsPrefix({ network, accountAddress }) + + accountAssetHoldings.transact(() => { + const existingKeys: string[] = [] + for (const [key] of accountAssetHoldings.entriesWithPrefix(prefix)) { + existingKeys.push(key) + } + for (const key of existingKeys) { + accountAssetHoldings.delete(key) + } - for (const holding of holdings) { - await db - .insert(AccountAssetHoldingsSchema) - .values({ + for (const holding of holdings) { + accountAssetHoldings.upsert({ + network, accountAddress, assetId: new Decimal(holding.assetId), - network, amount: holding.amount, updatedAt: now, }) - .run() - } + } + }) } -type InsertAssetHoldingParams = { - db?: Database +type InsertAssetHoldingParams = WithRegistry & { accountAddress: string assetId: string network: string @@ -74,25 +97,32 @@ type InsertAssetHoldingParams = { } export async function insertAssetHolding({ - db = getDatabase(), + registry, accountAddress, assetId, network, amount, }: InsertAssetHoldingParams): Promise { - await db - .insert(AccountAssetHoldingsSchema) - .values({ - accountAddress, - assetId: new Decimal(assetId), - network, - amount: new Decimal(amount ?? '0'), - updatedAt: Date.now(), - }) - .onConflictDoNothing() - .run() + const { accountAssetHoldings } = resolveRegistry(registry) + const key = accountAssetHoldingsKey({ network, accountAddress, assetId }) + + // Mirror the old `.onConflictDoNothing()` semantics: if a row already + // exists for this (network, account, assetId), leave it alone. + if (accountAssetHoldings.has(key)) return + + accountAssetHoldings.upsert({ + network, + accountAddress, + assetId: new Decimal(assetId), + amount: new Decimal(amount ?? '0'), + updatedAt: Date.now(), + }) } +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + export type AccountHoldingsFilters = { /** When true, rows with amount === 0 are excluded. */ hideZeroBalance?: boolean @@ -104,14 +134,24 @@ export type AccountHoldingsFilters = { excludeAssetTypes?: string[] } -type GetAccountHoldingsParams = { - db?: Database +type GetAccountHoldingsParams = WithRegistry & { accountAddress: string network: string } & AccountHoldingsFilters +/** + * Return every holding for one (network, account), optionally filtered + * by zero balance or asset type. + * + * The old SQL version LEFT JOINed `account_asset_holdings` against + * `assets_pera` to look up `asset_type` for the NFT filter. Here we + * scan the holdings collection by prefix and sync-lookup each asset's + * pera metadata in the `assets_pera` collection via `.get()` — that's + * one `Map.get` per holding, which is faster than a SQL join for the + * sizes we deal with (10s to 100s of holdings per account). + */ export async function getAccountHoldings({ - db = getDatabase(), + registry, accountAddress, network, hideZeroBalance, @@ -119,99 +159,55 @@ export async function getAccountHoldings({ hideOptedInNfts, excludeAssetTypes, }: GetAccountHoldingsParams): Promise { - const needsAssetJoin = + const { accountAssetHoldings, assetsPera } = resolveRegistry(registry) + const prefix = accountAssetHoldingsPrefix({ network, accountAddress }) + + const needsAssetLookup = hideNfts === true || hideOptedInNfts === true || !!excludeAssetTypes?.length - const baseConditions = [ - eq(AccountAssetHoldingsSchema.accountAddress, accountAddress), - eq(AccountAssetHoldingsSchema.network, network), - ] - - if (hideZeroBalance) { - // Decimal columns are stored as TEXT and normalized via Decimal#toString, - // so a zero amount is always the literal "0". - baseConditions.push( - ne(AccountAssetHoldingsSchema.amount, new Decimal(0)), - ) - } - - if (!needsAssetJoin) { - const rows = await db - .select({ - assetId: AccountAssetHoldingsSchema.assetId, - amount: AccountAssetHoldingsSchema.amount, - }) - .from(AccountAssetHoldingsSchema) - .where(and(...baseConditions)) - .all() - - return rows.map(r => ({ - assetId: r.assetId.toString(), - amount: r.amount, - })) - } - - const joinConditions = [...baseConditions] - - if (hideNfts) { - // Exclude any holding whose asset type is collectible. Unknown - // (NULL) asset types are kept since we can't yet classify them. - joinConditions.push( - or( - isNull(AssetsPeraSchema.assetType), - ne(AssetsPeraSchema.assetType, PeraAssetType.collectible), - )!, - ) - } else if (hideOptedInNfts) { - // Keep all non-NFT holdings, plus NFT holdings with a non-zero balance. - joinConditions.push( - or( - isNull(AssetsPeraSchema.assetType), - ne(AssetsPeraSchema.assetType, PeraAssetType.collectible), - ne(AccountAssetHoldingsSchema.amount, new Decimal(0)), - )!, - ) - } - - if (excludeAssetTypes?.length) { - joinConditions.push( - or( - isNull(AssetsPeraSchema.assetType), - notInArray(AssetsPeraSchema.assetType, excludeAssetTypes), - )!, - ) - } - - const rows = await db - .select({ - assetId: AccountAssetHoldingsSchema.assetId, - amount: AccountAssetHoldingsSchema.amount, + const excludeSet = new Set(excludeAssetTypes ?? []) + const results: HoldingRow[] = [] + + for (const [, holding] of accountAssetHoldings.entriesWithPrefix(prefix)) { + const isZero = holding.amount.isZero() + if (hideZeroBalance && isZero) continue + + let assetType: string | null = null + if (needsAssetLookup) { + const peraRow = assetsPera.get( + assetsPeraKey({ network, assetId: holding.assetId }), + ) + assetType = peraRow?.assetType ?? null + } + + if (hideNfts && assetType === PeraAssetType.collectible) continue + + if ( + hideOptedInNfts && + !hideNfts && + assetType === PeraAssetType.collectible && + isZero + ) { + continue + } + + if (assetType !== null && excludeSet.has(assetType)) continue + + results.push({ + assetId: holding.assetId.toString(), + amount: holding.amount, }) - .from(AccountAssetHoldingsSchema) - .leftJoin( - AssetsPeraSchema, - and( - eq( - AccountAssetHoldingsSchema.assetId, - AssetsPeraSchema.assetId, - ), - eq( - AccountAssetHoldingsSchema.network, - AssetsPeraSchema.network, - ), - ), - ) - .where(and(...joinConditions)) - .all() + } - return rows.map(r => ({ - assetId: r.assetId.toString(), - amount: r.amount, - })) + return results } +// --------------------------------------------------------------------------- +// Balances +// --------------------------------------------------------------------------- + export type AccountBalanceRow = { accountAddress: string algoBalance: Decimal @@ -223,8 +219,7 @@ export type AccountBalanceRow = { authAddress: string | null } -type UpsertAccountBalanceParams = { - db?: Database +type UpsertAccountBalanceParams = WithRegistry & { accountAddress: string network: string algoBalance: Decimal @@ -237,7 +232,7 @@ type UpsertAccountBalanceParams = { } export async function upsertAccountBalance({ - db = getDatabase(), + registry, accountAddress, network, algoBalance, @@ -248,154 +243,119 @@ export async function upsertAccountBalance({ status, authAddress, }: UpsertAccountBalanceParams): Promise { - const now = Date.now() - - await db - .insert(AccountBalancesSchema) - .values({ - accountAddress, - network, - algoBalance, - totalAssetsOptedIn, - totalCreatedAssets, - totalAppsOptedIn, - minBalance, - status, - authAddress, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - AccountBalancesSchema.accountAddress, - AccountBalancesSchema.network, - ], - set: { - algoBalance, - totalAssetsOptedIn, - totalCreatedAssets, - totalAppsOptedIn, - minBalance, - status, - authAddress, - updatedAt: now, - }, - }) - .run() + const { accountBalances } = resolveRegistry(registry) + + accountBalances.upsert({ + network, + accountAddress, + algoBalance, + totalAssetsOptedIn, + totalCreatedAssets, + totalAppsOptedIn, + minBalance, + status, + authAddress, + updatedAt: Date.now(), + }) } -type GetAccountBalanceParams = { - db?: Database +type GetAccountBalanceParams = WithRegistry & { accountAddress: string network: string } +function rowToBalance(row: AccountBalanceCollectionRow): AccountBalanceRow { + return { + accountAddress: row.accountAddress, + algoBalance: row.algoBalance, + totalAssetsOptedIn: row.totalAssetsOptedIn, + totalCreatedAssets: row.totalCreatedAssets, + totalAppsOptedIn: row.totalAppsOptedIn, + minBalance: row.minBalance, + status: row.status, + authAddress: row.authAddress, + } +} + export async function getAccountBalance({ - db = getDatabase(), + registry, accountAddress, network, }: GetAccountBalanceParams): Promise { - const rows = await db - .select({ - accountAddress: AccountBalancesSchema.accountAddress, - algoBalance: AccountBalancesSchema.algoBalance, - totalAssetsOptedIn: AccountBalancesSchema.totalAssetsOptedIn, - totalCreatedAssets: AccountBalancesSchema.totalCreatedAssets, - totalAppsOptedIn: AccountBalancesSchema.totalAppsOptedIn, - minBalance: AccountBalancesSchema.minBalance, - status: AccountBalancesSchema.status, - authAddress: AccountBalancesSchema.authAddress, - }) - .from(AccountBalancesSchema) - .where( - and( - eq(AccountBalancesSchema.accountAddress, accountAddress), - eq(AccountBalancesSchema.network, network), - ), - ) - .all() - - return rows[0] + const { accountBalances } = resolveRegistry(registry) + const row = accountBalances.get( + accountBalancesKey({ network, accountAddress }), + ) + return row ? rowToBalance(row) : undefined } -type GetAllAccountBalancesParams = { - db?: Database +type GetAllAccountBalancesParams = WithRegistry & { accountAddresses: string[] network: string } export async function getAllAccountBalances({ - db = getDatabase(), + registry, accountAddresses, network, }: GetAllAccountBalancesParams): Promise { if (accountAddresses.length === 0) return [] - return db - .select({ - accountAddress: AccountBalancesSchema.accountAddress, - algoBalance: AccountBalancesSchema.algoBalance, - totalAssetsOptedIn: AccountBalancesSchema.totalAssetsOptedIn, - totalCreatedAssets: AccountBalancesSchema.totalCreatedAssets, - totalAppsOptedIn: AccountBalancesSchema.totalAppsOptedIn, - minBalance: AccountBalancesSchema.minBalance, - status: AccountBalancesSchema.status, - authAddress: AccountBalancesSchema.authAddress, - }) - .from(AccountBalancesSchema) - .where( - and( - inArray(AccountBalancesSchema.accountAddress, accountAddresses), - eq(AccountBalancesSchema.network, network), - ), + const { accountBalances } = resolveRegistry(registry) + const results: AccountBalanceRow[] = [] + for (const accountAddress of accountAddresses) { + const row = accountBalances.get( + accountBalancesKey({ network, accountAddress }), ) - .all() + if (row !== undefined) results.push(rowToBalance(row)) + } + return results } -type DeleteAssetHoldingsParams = { - db?: Database +type DeleteAssetHoldingsParams = WithRegistry & { accountAddress: string assetIds: string[] network: string } export async function deleteAssetHoldings({ - db = getDatabase(), + registry, accountAddress, assetIds, network, }: DeleteAssetHoldingsParams): Promise { if (assetIds.length === 0) return - const assetIdDecimals = assetIds.map(id => new Decimal(id)) + const { accountAssetHoldings } = resolveRegistry(registry) - await db - .delete(AccountAssetHoldingsSchema) - .where( - and( - eq(AccountAssetHoldingsSchema.accountAddress, accountAddress), - eq(AccountAssetHoldingsSchema.network, network), - inArray(AccountAssetHoldingsSchema.assetId, assetIdDecimals), - ), - ) - .run() + accountAssetHoldings.transact(() => { + for (const assetId of assetIds) { + accountAssetHoldings.delete( + accountAssetHoldingsKey({ network, accountAddress, assetId }), + ) + } + }) } -type GetAllHoldingsForNetworkParams = { - db?: Database +type GetAllAssetIdsForNetworkParams = WithRegistry & { network: string } +/** + * Return every distinct asset id that appears in any holding for the + * given network. Used by SyncService to know which asset metadata rows + * to refresh. Infrequent (once per sync tick), so the O(holdings) + * scan is fine. + */ export async function getAllAssetIdsForNetwork({ - db = getDatabase(), + registry, network, -}: GetAllHoldingsForNetworkParams): Promise { - const rows = await db - .selectDistinct({ - assetId: AccountAssetHoldingsSchema.assetId, - }) - .from(AccountAssetHoldingsSchema) - .where(eq(AccountAssetHoldingsSchema.network, network)) - .all() - - return rows.map(r => r.assetId.toString()) +}: GetAllAssetIdsForNetworkParams): Promise { + const { accountAssetHoldings } = resolveRegistry(registry) + const prefix = `${network}:` + const seen = new Set() + for (const [, holding] of accountAssetHoldings.entriesWithPrefix(prefix)) { + seen.add(holding.assetId.toString()) + } + return [...seen] } diff --git a/packages/assets/src/db/__tests__/repository.spec.ts b/packages/assets/src/db/__tests__/repository.spec.ts index 3178672f9..775b0ec35 100644 --- a/packages/assets/src/db/__tests__/repository.spec.ts +++ b/packages/assets/src/db/__tests__/repository.spec.ts @@ -13,11 +13,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Decimal } from 'decimal.js' import { - runMigrations, - migrations, - type Database, + bootstrapTestCollections, + resetRegistryForTest, + type CollectionRegistry, } from '@perawallet/wallet-core-database' -import { createTestDatabase } from '@perawallet/wallet-core-database/test-utils' import type { PeraAsset } from '../../models' import { upsertAssets, @@ -28,18 +27,14 @@ import { } from '../repository' describe('asset repository', () => { - let db: Database - let teardown: () => void - - beforeEach(async () => { - const result = createTestDatabase() - db = result.db - teardown = result.teardown - await runMigrations(db, migrations) + let registry: CollectionRegistry + + beforeEach(() => { + registry = bootstrapTestCollections() }) afterEach(() => { - teardown() + resetRegistryForTest() }) const makeAsset = (overrides: Partial = {}): PeraAsset => ({ @@ -61,13 +56,13 @@ describe('asset repository', () => { describe('assets', () => { it('inserts and retrieves assets', async () => { await upsertAssets({ - db, + registry, items: [makeAsset()], network: 'mainnet', }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -82,18 +77,18 @@ describe('asset repository', () => { it('updates existing assets on conflict', async () => { await upsertAssets({ - db, + registry, items: [makeAsset({ name: 'Old Name' })], network: 'mainnet', }) await upsertAssets({ - db, + registry, items: [makeAsset({ name: 'New Name' })], network: 'mainnet', }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -104,7 +99,7 @@ describe('asset repository', () => { it('returns empty array for unknown IDs', async () => { const result = await getAssetsByIds({ - db, + registry, assetIds: ['999999'], network: 'mainnet', }) @@ -114,7 +109,7 @@ describe('asset repository', () => { it('returns empty array for empty input', async () => { const result = await getAssetsByIds({ - db, + registry, assetIds: [], network: 'mainnet', }) @@ -124,23 +119,23 @@ describe('asset repository', () => { it('isolates assets by network', async () => { await upsertAssets({ - db, + registry, items: [makeAsset({ assetId: '100' })], network: 'mainnet', }) await upsertAssets({ - db, + registry, items: [makeAsset({ assetId: '100', name: 'Testnet Asset' })], network: 'testnet', }) const mainnet = await getAssetsByIds({ - db, + registry, assetIds: ['100'], network: 'mainnet', }) const testnet = await getAssetsByIds({ - db, + registry, assetIds: ['100'], network: 'testnet', }) @@ -162,10 +157,10 @@ describe('asset repository', () => { }, }) - await upsertAssets({ db, items: [asset], network: 'mainnet' }) + await upsertAssets({ registry, items: [asset], network: 'mainnet' }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -181,10 +176,10 @@ describe('asset repository', () => { makeAsset({ assetId: '3', name: 'Asset 3' }), ] - await upsertAssets({ db, items, network: 'mainnet' }) + await upsertAssets({ registry, items, network: 'mainnet' }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['1', '2', '3'], network: 'mainnet', }) @@ -193,10 +188,10 @@ describe('asset repository', () => { }) it('does nothing for empty items', async () => { - await upsertAssets({ db, items: [], network: 'mainnet' }) + await upsertAssets({ registry, items: [], network: 'mainnet' }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -208,7 +203,7 @@ describe('asset repository', () => { describe('updateAssetPeraMetadata', () => { it('updates specific metadata fields without overwriting others', async () => { await upsertAssets({ - db, + registry, items: [ makeAsset({ peraMetadata: { @@ -224,14 +219,14 @@ describe('asset repository', () => { }) await updateAssetPeraMetadata({ - db, + registry, assetId: '31566704', network: 'mainnet', updates: { isFavorited: true }, }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -243,7 +238,7 @@ describe('asset repository', () => { it('updates isPriceAlertEnabled without overwriting isFavorited', async () => { await upsertAssets({ - db, + registry, items: [ makeAsset({ peraMetadata: { @@ -258,14 +253,14 @@ describe('asset repository', () => { }) await updateAssetPeraMetadata({ - db, + registry, assetId: '31566704', network: 'mainnet', updates: { isPriceAlertEnabled: true }, }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) @@ -276,14 +271,14 @@ describe('asset repository', () => { it('does nothing when asset does not exist', async () => { await updateAssetPeraMetadata({ - db, + registry, assetId: '999999', network: 'mainnet', updates: { isFavorited: true }, }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['999999'], network: 'mainnet', }) @@ -294,9 +289,8 @@ describe('asset repository', () => { describe('sync preserves device-specific fields', () => { it('preserves isFavorited and isPriceAlertEnabled when sync overwrites metadata', async () => { - // Initial sync stores asset with defaults await upsertAssets({ - db, + registry, items: [ makeAsset({ peraMetadata: { @@ -310,17 +304,15 @@ describe('asset repository', () => { network: 'mainnet', }) - // User toggles favorite and price alert await updateAssetPeraMetadata({ - db, + registry, assetId: '31566704', network: 'mainnet', updates: { isFavorited: true, isPriceAlertEnabled: true }, }) - // Sync runs again with defaults (API doesn't return device-specific fields) await upsertAssets({ - db, + registry, items: [ makeAsset({ peraMetadata: { @@ -335,12 +327,11 @@ describe('asset repository', () => { }) const result = await getAssetsByIds({ - db, + registry, assetIds: ['31566704'], network: 'mainnet', }) - // Device-specific fields should be preserved from the toggle mutation expect(result[0].peraMetadata?.isFavorited).toBe(true) expect(result[0].peraMetadata?.isPriceAlertEnabled).toBe(true) }) @@ -349,7 +340,7 @@ describe('asset repository', () => { describe('asset prices', () => { it('inserts and retrieves prices', async () => { await upsertAssetPrices({ - db, + registry, prices: [ { assetId: '100', usdPrice: new Decimal('1.50') }, { assetId: '200', usdPrice: new Decimal('0.75') }, @@ -358,7 +349,7 @@ describe('asset repository', () => { }) const result = await getAssetPricesByIds({ - db, + registry, assetIds: ['100', '200'], network: 'mainnet', }) @@ -371,19 +362,19 @@ describe('asset repository', () => { it('updates existing prices on conflict', async () => { await upsertAssetPrices({ - db, + registry, prices: [{ assetId: '100', usdPrice: new Decimal('1.00') }], network: 'mainnet', }) await upsertAssetPrices({ - db, + registry, prices: [{ assetId: '100', usdPrice: new Decimal('2.00') }], network: 'mainnet', }) const result = await getAssetPricesByIds({ - db, + registry, assetIds: ['100'], network: 'mainnet', }) @@ -394,13 +385,13 @@ describe('asset repository', () => { it('does nothing for empty prices', async () => { await upsertAssetPrices({ - db, + registry, prices: [], network: 'mainnet', }) const result = await getAssetPricesByIds({ - db, + registry, assetIds: ['100'], network: 'mainnet', }) diff --git a/packages/assets/src/db/__tests__/seed.spec.ts b/packages/assets/src/db/__tests__/seed.spec.ts index 83c9df05f..f49ed3b56 100644 --- a/packages/assets/src/db/__tests__/seed.spec.ts +++ b/packages/assets/src/db/__tests__/seed.spec.ts @@ -12,40 +12,35 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { - runMigrations, - migrations, - type Database, + bootstrapTestCollections, + resetRegistryForTest, + type CollectionRegistry, } from '@perawallet/wallet-core-database' -import { createTestDatabase } from '@perawallet/wallet-core-database/test-utils' import { ALGO_ASSET_ID } from '../../models' import { getAssetsByIds } from '../repository' import { seedAlgoAsset } from '../seed' describe('seedAlgoAsset', () => { - let db: Database - let teardown: () => void + let registry: CollectionRegistry - beforeEach(async () => { - const result = createTestDatabase() - db = result.db - teardown = result.teardown - await runMigrations(db, migrations) + beforeEach(() => { + registry = bootstrapTestCollections() }) afterEach(() => { - teardown() + resetRegistryForTest() }) it('seeds ALGO into both mainnet and testnet', async () => { - await seedAlgoAsset(db) + await seedAlgoAsset(registry) const mainnet = await getAssetsByIds({ - db, + registry, assetIds: [ALGO_ASSET_ID], network: 'mainnet', }) const testnet = await getAssetsByIds({ - db, + registry, assetIds: [ALGO_ASSET_ID], network: 'testnet', }) @@ -61,11 +56,11 @@ describe('seedAlgoAsset', () => { }) it('is idempotent — running twice does not duplicate', async () => { - await seedAlgoAsset(db) - await seedAlgoAsset(db) + await seedAlgoAsset(registry) + await seedAlgoAsset(registry) const result = await getAssetsByIds({ - db, + registry, assetIds: [ALGO_ASSET_ID], network: 'mainnet', }) diff --git a/packages/assets/src/db/repository.ts b/packages/assets/src/db/repository.ts index 414ccda12..b74e4b6be 100644 --- a/packages/assets/src/db/repository.ts +++ b/packages/assets/src/db/repository.ts @@ -10,404 +10,333 @@ limitations under the License */ -import { eq, and, inArray } from 'drizzle-orm' import { Decimal } from 'decimal.js' -import { getDatabase, type Database } from '@perawallet/wallet-core-database' +import { + assetPricesKey, + assetsNodeKey, + assetsPeraKey, + getCollections, + type AssetPriceCollectionRow, + type AssetsNodeRow, + type AssetsPeraRow, + type CollectionRegistry, +} from '@perawallet/wallet-core-database' import { DEFAULT_ASSET_METADATA, type PeraAsset, type PeraAssetMetadata, } from '../models' -import { AssetsNodeSchema, AssetsPeraSchema, AssetPricesSchema } from './schema' - -function fromDb(row: { - assetId: Decimal - decimals: number - creatorAddress: string - totalSupply: Decimal - name: string | null - unitName: string | null - url: string | null - metadata: string | null - peraMetadataJson: string | null -}): PeraAsset { - const peraMetadata: PeraAssetMetadata | undefined = row.peraMetadataJson - ? (JSON.parse(row.peraMetadataJson) as PeraAssetMetadata) - : undefined +type WithRegistry = { registry?: CollectionRegistry } + +function resolveRegistry(registry: CollectionRegistry | undefined): CollectionRegistry { + return registry ?? getCollections() +} + +function parseMetaJson(json: string | null): PeraAssetMetadata | undefined { + if (!json) return undefined + try { + return JSON.parse(json) as PeraAssetMetadata + } catch { + return undefined + } +} + +function rowsToPeraAsset( + node: AssetsNodeRow, + pera: AssetsPeraRow | undefined, +): PeraAsset { return { - assetId: row.assetId.toString(), - decimals: row.decimals, - creator: { address: row.creatorAddress }, - totalSupply: row.totalSupply, - name: row.name ?? undefined, - unitName: row.unitName ?? undefined, - url: row.url ?? undefined, - metadata: row.metadata ?? undefined, - peraMetadata, + assetId: node.assetId.toString(), + decimals: node.decimals, + creator: { address: node.creatorAddress }, + totalSupply: node.totalSupply, + name: node.name ?? undefined, + unitName: node.unitName ?? undefined, + url: node.url ?? undefined, + metadata: node.metadata ?? undefined, + peraMetadata: parseMetaJson(pera?.peraMetadataJson ?? null), } } -type UpsertNodeAssetsParams = { - db?: Database +// --------------------------------------------------------------------------- +// Node-level asset writes +// --------------------------------------------------------------------------- + +type UpsertNodeAssetsParams = WithRegistry & { items: PeraAsset[] network: string } export async function upsertNodeAssets({ - db = getDatabase(), + registry, items, network, }: UpsertNodeAssetsParams): Promise { if (items.length === 0) return + const { assetsNode } = resolveRegistry(registry) const now = Date.now() - for (const item of items) { - await db - .insert(AssetsNodeSchema) - .values({ - assetId: new Decimal(item.assetId), - network, - decimals: item.decimals, - creatorAddress: item.creator.address, - totalSupply: item.totalSupply, - name: item.name ?? null, - unitName: item.unitName ?? null, - url: item.url ?? null, - metadata: item.metadata ?? null, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [AssetsNodeSchema.assetId, AssetsNodeSchema.network], - set: { - decimals: item.decimals, - creatorAddress: item.creator.address, - totalSupply: item.totalSupply, - name: item.name ?? null, - unitName: item.unitName ?? null, - url: item.url ?? null, - metadata: item.metadata ?? null, - updatedAt: now, - }, - }) - .run() - } + assetsNode.upsertMany( + items.map(item => ({ + network, + assetId: new Decimal(item.assetId), + decimals: item.decimals, + creatorAddress: item.creator.address, + totalSupply: item.totalSupply, + name: item.name ?? null, + unitName: item.unitName ?? null, + url: item.url ?? null, + metadata: item.metadata ?? null, + updatedAt: now, + })), + ) } -type UpsertPeraAssetsParams = { - db?: Database +// --------------------------------------------------------------------------- +// Pera-specific asset writes (preserves device-local fields) +// --------------------------------------------------------------------------- + +type UpsertPeraAssetsParams = WithRegistry & { items: PeraAsset[] network: string } +/** + * Batch upsert pera metadata, preserving device-local fields. + * + * `isFavorited` and `isPriceAlertEnabled` are set only by user toggles + * (see `updateAssetPeraMetadata`) and are absent from the sync API + * payload. We must not clobber them when the syncer writes a fresh row. + * + * Strategy: read the existing row first (sync map lookup) and merge the + * two device-local fields into the incoming payload before writing. + */ export async function upsertPeraAssets({ - db = getDatabase(), + registry, items, network, }: UpsertPeraAssetsParams): Promise { if (items.length === 0) return + const { assetsPera } = resolveRegistry(registry) const now = Date.now() - const decimalIds = items.map(i => new Decimal(i.assetId)) - - // Read existing metadata to preserve device-specific fields (isFavorited, isPriceAlertEnabled) - // that are only set by toggle mutations and not returned by the sync API - const existingRows = await db - .select({ - assetId: AssetsPeraSchema.assetId, - peraMetadataJson: AssetsPeraSchema.peraMetadataJson, - }) - .from(AssetsPeraSchema) - .where( - and( - inArray(AssetsPeraSchema.assetId, decimalIds), - eq(AssetsPeraSchema.network, network), - ), - ) - .all() - - const existingMetaMap = new Map() - for (const row of existingRows) { - if (row.peraMetadataJson) { - existingMetaMap.set( - row.assetId.toString(), - JSON.parse(row.peraMetadataJson) as PeraAssetMetadata, - ) - } - } - - for (const item of items) { - const meta = item.peraMetadata - const existing = existingMetaMap.get(item.assetId) - - const mergedMeta = meta - ? { - ...meta, - isFavorited: existing?.isFavorited ?? meta.isFavorited, - isPriceAlertEnabled: - existing?.isPriceAlertEnabled ?? meta.isPriceAlertEnabled, - } - : undefined - const metaJson = mergedMeta ? JSON.stringify(mergedMeta) : null + assetsPera.transact(() => { + for (const item of items) { + const meta = item.peraMetadata + const existingRow = assetsPera.get( + assetsPeraKey({ network, assetId: item.assetId }), + ) + const existingMeta = parseMetaJson( + existingRow?.peraMetadataJson ?? null, + ) - await db - .insert(AssetsPeraSchema) - .values({ - assetId: new Decimal(item.assetId), + const mergedMeta: PeraAssetMetadata | undefined = meta + ? { + ...meta, + isFavorited: + existingMeta?.isFavorited ?? meta.isFavorited, + isPriceAlertEnabled: + existingMeta?.isPriceAlertEnabled ?? + meta.isPriceAlertEnabled, + } + : undefined + + assetsPera.upsert({ network, + assetId: new Decimal(item.assetId), verificationTier: meta?.verificationTier ?? 'unverified', isDeleted: meta?.isDeleted ?? false, assetType: meta?.type ?? null, - peraMetadataJson: metaJson, + peraMetadataJson: mergedMeta ? JSON.stringify(mergedMeta) : null, updatedAt: now, }) - .onConflictDoUpdate({ - target: [AssetsPeraSchema.assetId, AssetsPeraSchema.network], - set: { - verificationTier: meta?.verificationTier ?? 'unverified', - isDeleted: meta?.isDeleted ?? false, - assetType: meta?.type ?? null, - peraMetadataJson: metaJson, - updatedAt: now, - }, - }) - .run() - } + } + }) } -type UpsertAssetsParams = { - db?: Database +// --------------------------------------------------------------------------- +// Unified upsert — writes both tables +// --------------------------------------------------------------------------- + +type UpsertAssetsParams = WithRegistry & { items: PeraAsset[] network: string } export async function upsertAssets({ - db = getDatabase(), + registry, items, network, }: UpsertAssetsParams): Promise { - await upsertNodeAssets({ db, items, network }) - await upsertPeraAssets({ db, items, network }) + await upsertNodeAssets({ registry, items, network }) + await upsertPeraAssets({ registry, items, network }) } -type GetAssetsByIdsParams = { - db?: Database +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +type GetAssetsByIdsParams = WithRegistry & { assetIds: string[] network: string } export async function getAssetsByIds({ - db = getDatabase(), + registry, assetIds, network, }: GetAssetsByIdsParams): Promise { if (assetIds.length === 0) return [] - const decimalIds = assetIds.map(id => new Decimal(id)) - - const rows = await db - .select({ - assetId: AssetsNodeSchema.assetId, - decimals: AssetsNodeSchema.decimals, - creatorAddress: AssetsNodeSchema.creatorAddress, - totalSupply: AssetsNodeSchema.totalSupply, - name: AssetsNodeSchema.name, - unitName: AssetsNodeSchema.unitName, - url: AssetsNodeSchema.url, - metadata: AssetsNodeSchema.metadata, - peraMetadataJson: AssetsPeraSchema.peraMetadataJson, - }) - .from(AssetsNodeSchema) - .leftJoin( - AssetsPeraSchema, - and( - eq(AssetsNodeSchema.assetId, AssetsPeraSchema.assetId), - eq(AssetsNodeSchema.network, AssetsPeraSchema.network), - ), - ) - .where( - and( - inArray(AssetsNodeSchema.assetId, decimalIds), - eq(AssetsNodeSchema.network, network), - ), - ) - .all() - - return rows.map(fromDb) + const { assetsNode, assetsPera } = resolveRegistry(registry) + const results: PeraAsset[] = [] + for (const assetId of assetIds) { + const node = assetsNode.get(assetsNodeKey({ network, assetId })) + if (node === undefined) continue + const pera = assetsPera.get(assetsPeraKey({ network, assetId })) + results.push(rowsToPeraAsset(node, pera)) + } + return results } -type GetAssetByIdParams = { - db?: Database +type GetAssetByIdParams = WithRegistry & { assetId: string network: string } export async function getAssetById({ - db = getDatabase(), + registry, assetId, network, }: GetAssetByIdParams): Promise { - const results = await getAssetsByIds({ db, assetIds: [assetId], network }) + const results = await getAssetsByIds({ + registry, + assetIds: [assetId], + network, + }) return results[0] ?? null } -type GetAssetPeraMetadataParams = { - db?: Database +type GetAssetPeraMetadataParams = WithRegistry & { assetId: string network: string } export async function getAssetPeraMetadata({ - db = getDatabase(), + registry, assetId, network, }: GetAssetPeraMetadataParams): Promise { - const rows = await db - .select({ peraMetadataJson: AssetsPeraSchema.peraMetadataJson }) - .from(AssetsPeraSchema) - .where( - and( - eq(AssetsPeraSchema.assetId, new Decimal(assetId)), - eq(AssetsPeraSchema.network, network), - ), - ) - .all() - - if (!rows[0]?.peraMetadataJson) return null - return JSON.parse(rows[0].peraMetadataJson) as PeraAssetMetadata + const { assetsPera } = resolveRegistry(registry) + const row = assetsPera.get(assetsPeraKey({ network, assetId })) + return parseMetaJson(row?.peraMetadataJson ?? null) ?? null } -type UpdateAssetPeraMetadataParams = { - db?: Database +// --------------------------------------------------------------------------- +// Update asset metadata from the user side (favorites, price alerts) +// --------------------------------------------------------------------------- + +type UpdateAssetPeraMetadataParams = WithRegistry & { assetId: string network: string updates: Partial } +/** + * Read-merge-write for user-driven metadata toggles. + * + * Semantics mirror the old SQL path: start from defaults, layer the + * persisted metadata on top, then apply the incoming updates last so + * `updates` always wins. This is the place `isFavorited` / + * `isPriceAlertEnabled` get written — `upsertPeraAssets` (sync path) + * never touches them. + */ export async function updateAssetPeraMetadata({ - db = getDatabase(), + registry, assetId, network, updates, }: UpdateAssetPeraMetadataParams): Promise { - const decimalId = new Decimal(assetId) - const now = Date.now() - - const rows = await db - .select({ peraMetadataJson: AssetsPeraSchema.peraMetadataJson }) - .from(AssetsPeraSchema) - .where( - and( - eq(AssetsPeraSchema.assetId, decimalId), - eq(AssetsPeraSchema.network, network), - ), - ) - .all() - - const existing: PeraAssetMetadata | undefined = rows[0]?.peraMetadataJson - ? (JSON.parse(rows[0].peraMetadataJson) as PeraAssetMetadata) - : undefined + const { assetsPera } = resolveRegistry(registry) + const key = assetsPeraKey({ network, assetId }) + const existingRow = assetsPera.get(key) + const existingMeta = parseMetaJson(existingRow?.peraMetadataJson ?? null) const merged: PeraAssetMetadata = { ...DEFAULT_ASSET_METADATA, - ...existing, + ...existingMeta, ...updates, } - const metaJson = JSON.stringify(merged) - await db - .insert(AssetsPeraSchema) - .values({ - assetId: decimalId, - network, - verificationTier: merged.verificationTier, - isDeleted: merged.isDeleted, - peraMetadataJson: metaJson, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [AssetsPeraSchema.assetId, AssetsPeraSchema.network], - set: { - peraMetadataJson: metaJson, - updatedAt: now, - }, - }) - .run() + assetsPera.upsert({ + network, + assetId: new Decimal(assetId), + verificationTier: merged.verificationTier, + isDeleted: merged.isDeleted, + assetType: existingRow?.assetType ?? null, + peraMetadataJson: JSON.stringify(merged), + updatedAt: Date.now(), + }) } +// --------------------------------------------------------------------------- +// Prices +// --------------------------------------------------------------------------- + export type AssetPriceRow = { assetId: string usdPrice: Decimal } -type UpsertAssetPricesParams = { - db?: Database +type UpsertAssetPricesParams = WithRegistry & { prices: AssetPriceRow[] network: string } export async function upsertAssetPrices({ - db = getDatabase(), + registry, prices, network, }: UpsertAssetPricesParams): Promise { if (prices.length === 0) return + const { assetPrices } = resolveRegistry(registry) const now = Date.now() - for (const price of prices) { - await db - .insert(AssetPricesSchema) - .values({ - assetId: new Decimal(price.assetId), - network, - usdPrice: price.usdPrice, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [AssetPricesSchema.assetId, AssetPricesSchema.network], - set: { - usdPrice: price.usdPrice, - updatedAt: now, - }, - }) - .run() - } + assetPrices.upsertMany( + prices.map(price => ({ + network, + assetId: new Decimal(price.assetId), + usdPrice: price.usdPrice, + updatedAt: now, + })), + ) } -type GetAssetPricesByIdsParams = { - db?: Database +type GetAssetPricesByIdsParams = WithRegistry & { assetIds: string[] network: string } export async function getAssetPricesByIds({ - db = getDatabase(), + registry, assetIds, network, }: GetAssetPricesByIdsParams): Promise { if (assetIds.length === 0) return [] - const decimalIds = assetIds.map(id => new Decimal(id)) - - const rows = await db - .select({ - assetId: AssetPricesSchema.assetId, - usdPrice: AssetPricesSchema.usdPrice, + const { assetPrices } = resolveRegistry(registry) + const results: AssetPriceRow[] = [] + for (const assetId of assetIds) { + const row = assetPrices.get(assetPricesKey({ network, assetId })) + if (row === undefined) continue + results.push({ + assetId: row.assetId.toString(), + usdPrice: row.usdPrice, }) - .from(AssetPricesSchema) - .where( - and( - inArray(AssetPricesSchema.assetId, decimalIds), - eq(AssetPricesSchema.network, network), - ), - ) - .all() - - return rows.map(r => ({ - assetId: r.assetId.toString(), - usdPrice: r.usdPrice, - })) + } + return results } diff --git a/packages/assets/src/db/seed.ts b/packages/assets/src/db/seed.ts index 6c3245705..29612fef2 100644 --- a/packages/assets/src/db/seed.ts +++ b/packages/assets/src/db/seed.ts @@ -10,13 +10,21 @@ limitations under the License */ -import type { Database } from '@perawallet/wallet-core-database' +import type { CollectionRegistry } from '@perawallet/wallet-core-database' import { ALGO_ASSET } from '../models' import { upsertAssets } from './repository' -export async function seedAlgoAsset(db: Database): Promise { +/** + * Seeds the ALGO asset into both mainnet and testnet. Called once during + * app bootstrap (after `bootstrapCollections`) so callers that read ALGO + * via the assets collections always get a hit, even before the first + * sync tick has run. + */ +export async function seedAlgoAsset( + registry?: CollectionRegistry, +): Promise { const items = [ALGO_ASSET] - await upsertAssets({ db, items, network: 'mainnet' }) - await upsertAssets({ db, items, network: 'testnet' }) + await upsertAssets({ registry, items, network: 'mainnet' }) + await upsertAssets({ registry, items, network: 'testnet' }) } diff --git a/packages/database/package.json b/packages/database/package.json index 276b0d757..b4b22e202 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -27,10 +27,16 @@ "drizzle-orm": "catalog:", "@perawallet/wallet-extension-platform": "workspace:*" }, + "peerDependencies": { + "react": "catalog:" + }, "devDependencies": { "better-sqlite3": "^11.0.0", "@types/better-sqlite3": "^7.6.0", + "@testing-library/react": "catalog:", + "@types/react": "catalog:", "drizzle-kit": "^0.30.0", + "react": "catalog:", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-dts": "^4.5.4", diff --git a/packages/database/src/collections/__tests__/collection.spec.ts b/packages/database/src/collections/__tests__/collection.spec.ts new file mode 100644 index 000000000..b37203dc9 --- /dev/null +++ b/packages/database/src/collections/__tests__/collection.spec.ts @@ -0,0 +1,198 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect } from 'vitest' +import { MemoryAdapter } from '../adapter/memory-adapter' +import { Collection } from '../collection' + +type Row = { network: string; address: string; value: number } + +function makeCollection(): { + collection: Collection + adapter: MemoryAdapter +} { + const adapter = new MemoryAdapter({ name: 'rows' }) + const collection = new Collection({ + name: 'rows', + adapter, + getKey: row => `${row.network}:${row.address}`, + }) + return { collection, adapter } +} + +describe('Collection', () => { + it('upsert stores values and writes through to the adapter', () => { + const { collection, adapter } = makeCollection() + collection.upsert({ network: 'main', address: 'a', value: 1 }) + + expect(collection.get('main:a')).toEqual({ + network: 'main', + address: 'a', + value: 1, + }) + expect(adapter.snapshot().get('main:a')).toEqual({ + network: 'main', + address: 'a', + value: 1, + }) + }) + + it('upsert notifies subscribers', () => { + const { collection } = makeCollection() + let calls = 0 + collection.subscribe(() => { + calls += 1 + }) + + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'a', value: 2 }) + + expect(calls).toBe(2) + }) + + it('transact batches notifications to one emit at commit', () => { + const { collection } = makeCollection() + let calls = 0 + collection.subscribe(() => { + calls += 1 + }) + + collection.transact(() => { + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'b', value: 2 }) + collection.upsert({ network: 'main', address: 'c', value: 3 }) + }) + + expect(calls).toBe(1) + expect(collection.size).toBe(3) + }) + + it('transact flushes to the adapter via putMany on commit', () => { + const { collection, adapter } = makeCollection() + + collection.transact(() => { + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'b', value: 2 }) + }) + + expect(adapter.snapshot().size).toBe(2) + }) + + it('nested transact collapses into the outermost commit', () => { + const { collection } = makeCollection() + let calls = 0 + collection.subscribe(() => { + calls += 1 + }) + + collection.transact(() => { + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.transact(() => { + collection.upsert({ network: 'main', address: 'b', value: 2 }) + }) + }) + + expect(calls).toBe(1) + }) + + it('delete removes the row and notifies subscribers', () => { + const { collection, adapter } = makeCollection() + collection.upsert({ network: 'main', address: 'a', value: 1 }) + + let notified = false + collection.subscribe(() => { + notified = true + }) + + expect(collection.delete('main:a')).toBe(true) + expect(collection.get('main:a')).toBeUndefined() + expect(adapter.snapshot().has('main:a')).toBe(false) + expect(notified).toBe(true) + }) + + it('delete of a missing key is a no-op and does not notify', () => { + const { collection } = makeCollection() + let notified = false + collection.subscribe(() => { + notified = true + }) + expect(collection.delete('missing')).toBe(false) + expect(notified).toBe(false) + }) + + it('entriesWithPrefix scans composite-key prefixes', () => { + const { collection } = makeCollection() + collection.transact(() => { + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'b', value: 2 }) + collection.upsert({ network: 'test', address: 'a', value: 3 }) + }) + + const mainRows = [...collection.entriesWithPrefix('main:')] + expect(mainRows).toHaveLength(2) + expect(mainRows.map(([_, r]) => r.address).sort()).toEqual(['a', 'b']) + }) + + it('deleteWhere removes matching rows atomically and notifies once', () => { + const { collection } = makeCollection() + collection.transact(() => { + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'b', value: 2 }) + collection.upsert({ network: 'test', address: 'a', value: 3 }) + }) + + let calls = 0 + collection.subscribe(() => { + calls += 1 + }) + + const removed = collection.deleteWhere(row => row.network === 'main') + + expect(removed).toBe(2) + expect(collection.size).toBe(1) + expect(calls).toBe(1) + }) + + it('clear wipes state and adapter in one notification', () => { + const { collection, adapter } = makeCollection() + collection.upsert({ network: 'main', address: 'a', value: 1 }) + collection.upsert({ network: 'main', address: 'b', value: 2 }) + + let calls = 0 + collection.subscribe(() => { + calls += 1 + }) + + collection.clear() + + expect(collection.size).toBe(0) + expect(adapter.snapshot().size).toBe(0) + expect(calls).toBe(1) + }) + + it('hydrate is called once at construction, so rehydrating is idempotent for the adapter', () => { + const adapter = new MemoryAdapter({ name: 'rows' }) + adapter.put('main:seed', { network: 'main', address: 'seed', value: 99 }) + + const collection = new Collection({ + name: 'rows', + adapter, + getKey: row => `${row.network}:${row.address}`, + }) + + expect(collection.get('main:seed')).toEqual({ + network: 'main', + address: 'seed', + value: 99, + }) + }) +}) diff --git a/packages/database/src/collections/__tests__/decimal-codec.spec.ts b/packages/database/src/collections/__tests__/decimal-codec.spec.ts new file mode 100644 index 000000000..840c224c3 --- /dev/null +++ b/packages/database/src/collections/__tests__/decimal-codec.spec.ts @@ -0,0 +1,77 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect } from 'vitest' +import { Decimal } from 'decimal.js' +import { decode, encode } from '../adapter/decimal-codec' + +describe('decimal-codec', () => { + it('round-trips a Decimal preserving full precision beyond 2^53', () => { + // Algorand asset IDs and token totals routinely exceed 2^53 — + // native JS number would lose digits, which is the precise + // failure mode the codec exists to prevent. + const big = new Decimal('123456789012345678901234567890.123456789') + const value = { amount: big } + + const encoded = encode(value) + const decoded = decode(encoded) + + expect(decoded.amount).toBeInstanceOf(Decimal) + expect(decoded.amount.toString()).toBe(big.toString()) + }) + + it('round-trips nested Decimals inside arrays and objects', () => { + const value = { + label: 'wallet', + balances: [new Decimal('1.23'), new Decimal('4.56')], + nested: { + min: new Decimal('0.0000000001'), + meta: { label: 'nested' }, + }, + } + + const decoded = decode(encode(value)) + + expect(decoded.balances[0]).toBeInstanceOf(Decimal) + expect(decoded.balances[0].toString()).toBe('1.23') + expect(decoded.balances[1].toString()).toBe('4.56') + expect(decoded.nested.min).toBeInstanceOf(Decimal) + expect(decoded.nested.min.toString()).toBe('1e-10') + expect(decoded.nested.meta.label).toBe('nested') + }) + + it('leaves plain strings, numbers, nulls, and booleans untouched', () => { + const value = { + s: 'hello', + n: 42, + b: true, + maybe: null, + items: [1, 2, 3], + } + + const decoded = decode(encode(value)) + + expect(decoded).toEqual(value) + }) + + it('does not mistake an arbitrary object with a `__d` property for a Decimal', () => { + // The reviver deliberately requires the object to have EXACTLY one + // key named `__d`. Any object with extra keys should be left alone. + const value = { label: 'ok', __d: 'not really a decimal', extra: 1 } + + const decoded = decode(encode(value)) + + expect(decoded.label).toBe('ok') + expect(decoded.__d).toBe('not really a decimal') + expect(decoded.extra).toBe(1) + }) +}) diff --git a/packages/database/src/collections/__tests__/mmkv-adapter.spec.ts b/packages/database/src/collections/__tests__/mmkv-adapter.spec.ts new file mode 100644 index 000000000..ab2f0d9d3 --- /dev/null +++ b/packages/database/src/collections/__tests__/mmkv-adapter.spec.ts @@ -0,0 +1,126 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Decimal } from 'decimal.js' +import { InMemoryMmkv } from '../adapter/memory-adapter' +import { MmkvAdapter } from '../adapter/mmkv-adapter' + +type Row = { + id: string + amount: Decimal + updatedAt: number +} + +function makeAdapter( + mmkv: InMemoryMmkv, + schemaVersion = 1, +): MmkvAdapter { + return new MmkvAdapter({ + name: 'rows', + schemaVersion, + mmkv, + }) +} + +describe('MmkvAdapter', () => { + let mmkv: InMemoryMmkv + + beforeEach(() => { + mmkv = new InMemoryMmkv() + }) + + it('persists and hydrates rows through the Decimal codec', () => { + const adapter = makeAdapter(mmkv) + adapter.put('a', { + id: 'a', + amount: new Decimal('1000000000000.5'), + updatedAt: 1, + }) + adapter.put('b', { + id: 'b', + amount: new Decimal('2'), + updatedAt: 2, + }) + + const hydrated = makeAdapter(mmkv).hydrate() + + expect(hydrated.size).toBe(2) + expect(hydrated.get('a')?.amount).toBeInstanceOf(Decimal) + expect(hydrated.get('a')?.amount.toString()).toBe('1000000000000.5') + expect(hydrated.get('b')?.amount.toString()).toBe('2') + }) + + it('putMany writes every entry and deleteMany removes them', () => { + const adapter = makeAdapter(mmkv) + adapter.putMany([ + ['a', { id: 'a', amount: new Decimal(1), updatedAt: 1 }], + ['b', { id: 'b', amount: new Decimal(2), updatedAt: 2 }], + ['c', { id: 'c', amount: new Decimal(3), updatedAt: 3 }], + ]) + expect(adapter.hydrate().size).toBe(3) + + adapter.deleteMany(['a', 'b']) + const after = adapter.hydrate() + expect(after.size).toBe(1) + expect(after.has('c')).toBe(true) + }) + + it('deleteAll wipes every key belonging to this collection', () => { + const adapter = makeAdapter(mmkv) + adapter.put('a', { id: 'a', amount: new Decimal(1), updatedAt: 1 }) + adapter.put('b', { id: 'b', amount: new Decimal(2), updatedAt: 2 }) + + // Something else in MMKV (e.g. a Zustand persist key) — must + // survive deleteAll. + mmkv.set('zustand:accounts', '{}') + + adapter.deleteAll() + + expect(adapter.hydrate().size).toBe(0) + expect(mmkv.getString('zustand:accounts')).toBe('{}') + }) + + it('schema version bump drops all stored rows for this collection', () => { + const adapter = makeAdapter(mmkv, 1) + adapter.put('a', { id: 'a', amount: new Decimal(1), updatedAt: 1 }) + + // Bumping the schema version on a fresh adapter instance should + // wipe everything — rows are re-derivable from the network, so + // drop-and-rebuild is our migration story. + const bumped = makeAdapter(mmkv, 2) + + expect(bumped.hydrate().size).toBe(0) + }) + + it('drops corrupt rows on hydrate instead of crashing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const adapter = makeAdapter(mmkv) + adapter.put('good', { + id: 'good', + amount: new Decimal(1), + updatedAt: 1, + }) + + // Inject a corrupt blob under the same prefix — the adapter + // must drop it and keep going, not throw. + mmkv.set('tdb:rows:1:bad', '{ not valid json') + + const result = makeAdapter(mmkv).hydrate() + + expect(result.size).toBe(1) + expect(result.has('good')).toBe(true) + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) +}) diff --git a/packages/database/src/collections/adapter/decimal-codec.ts b/packages/database/src/collections/adapter/decimal-codec.ts new file mode 100644 index 000000000..f6c79c65b --- /dev/null +++ b/packages/database/src/collections/adapter/decimal-codec.ts @@ -0,0 +1,79 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { Decimal } from 'decimal.js' + +/** + * JSON codec that preserves `Decimal` values end-to-end. + * + * Every monetary/precision-sensitive field in the collection layer is typed + * as `Decimal` (amounts, balances, prices, fees, asset IDs, total supply). + * When we serialize a collection row to MMKV, these fields must round-trip + * without precision loss — JS `number` can't be used because values larger + * than 2^53 (asset IDs, token totals) lose digits. + * + * Encoding replaces every `Decimal` with a tagged marker `{ __d: "" }` + * by walking the object tree *before* `JSON.stringify` sees it. We cannot + * use the `JSON.stringify` replacer for this because `Decimal.prototype` + * defines `toJSON()` — `JSON.stringify` calls that first, so by the time + * the replacer runs the value has already been flattened to a plain + * string and `value instanceof Decimal` no longer holds. + * + * Decoding uses the `JSON.parse` reviver, which has no such complication: + * the reviver sees the raw parsed nodes and can pattern-match on the + * marker shape to rehydrate each `Decimal`. + */ + +type DecimalMarker = { __d: string } + +function isDecimalMarker(value: unknown): value is DecimalMarker { + if (value === null || typeof value !== 'object') return false + const obj = value as Record + const keys = Object.keys(obj) + return ( + keys.length === 1 && keys[0] === '__d' && typeof obj.__d === 'string' + ) +} + +function substituteDecimals(value: unknown): unknown { + if (value instanceof Decimal) { + return { __d: value.toString() } satisfies DecimalMarker + } + if (Array.isArray(value)) { + return value.map(substituteDecimals) + } + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [key, inner] of Object.entries( + value as Record, + )) { + out[key] = substituteDecimals(inner) + } + return out + } + return value +} + +function reviver(_key: string, value: unknown): unknown { + if (isDecimalMarker(value)) { + return new Decimal(value.__d) + } + return value +} + +export function encode(value: T): string { + return JSON.stringify(substituteDecimals(value)) +} + +export function decode(serialized: string): T { + return JSON.parse(serialized, reviver) as T +} diff --git a/packages/database/src/collections/adapter/index.ts b/packages/database/src/collections/adapter/index.ts new file mode 100644 index 000000000..f1e0010b1 --- /dev/null +++ b/packages/database/src/collections/adapter/index.ts @@ -0,0 +1,24 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export type { + CollectionKey, + MmkvLike, + PersistentAdapter, +} from './types' +export { MmkvAdapter, type MmkvAdapterOptions } from './mmkv-adapter' +export { MemoryAdapter, InMemoryMmkv } from './memory-adapter' +export { encode, decode } from './decimal-codec' +export { + fromKeyValueStorage, + type KeyValueStorageLike, +} from './key-value-adapter' diff --git a/packages/database/src/collections/adapter/key-value-adapter.ts b/packages/database/src/collections/adapter/key-value-adapter.ts new file mode 100644 index 000000000..3f8bb4506 --- /dev/null +++ b/packages/database/src/collections/adapter/key-value-adapter.ts @@ -0,0 +1,44 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { MmkvLike } from './types' + +/** + * Structural subset of the `KeyValueStorageService` interface defined in + * `@perawallet/wallet-extension-platform`. We re-declare it here instead + * of importing it because the collections layer must stay independent of + * the platform/extension packages — the adapter is driven by whatever + * key-value store the host app hands over at bootstrap. + */ +export interface KeyValueStorageLike { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + getAllKeys(): string[] +} + +/** + * Adapt a platform `KeyValueStorageService`-shaped object into the + * `MmkvLike` surface the MMKV adapter expects. The difference is + * cosmetic (method naming + `null` vs `undefined` for missing values), + * so the adaptation is a trivial one-liner — but centralizing it here + * means `App.tsx` can call `bootstrapCollections({ mmkv: fromKeyValueStorage(kvs) })` + * without reaching into RN-specific MMKV internals. + */ +export function fromKeyValueStorage(kvs: KeyValueStorageLike): MmkvLike { + return { + set: (key, value) => kvs.setItem(key, value), + getString: key => kvs.getItem(key) ?? undefined, + delete: key => kvs.removeItem(key), + getAllKeys: () => kvs.getAllKeys(), + } +} diff --git a/packages/database/src/collections/adapter/memory-adapter.ts b/packages/database/src/collections/adapter/memory-adapter.ts new file mode 100644 index 000000000..ea3cffc73 --- /dev/null +++ b/packages/database/src/collections/adapter/memory-adapter.ts @@ -0,0 +1,125 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { decode, encode } from './decimal-codec' +import type { + CollectionKey, + MmkvLike, + PersistentAdapter, +} from './types' + +/** + * In-memory stand-in for `react-native-mmkv`, suitable for unit tests and + * the Node-side vitest runner. Accepts/returns UTF-8 strings like MMKV + * does and supports `getAllKeys`, which is the only non-trivial surface + * the adapter touches. + */ +export class InMemoryMmkv implements MmkvLike { + private readonly store = new Map() + + set(key: string, value: string): void { + this.store.set(key, value) + } + + getString(key: string): string | undefined { + return this.store.get(key) + } + + delete(key: string): void { + this.store.delete(key) + } + + getAllKeys(): string[] { + return [...this.store.keys()] + } + + /** Test helper — used by `beforeEach` to wipe state between tests. */ + clear(): void { + this.store.clear() + } + + /** Test helper — inspect the raw storage. */ + snapshot(): ReadonlyMap { + return this.store + } +} + +/** + * A lightweight, in-process `PersistentAdapter` for unit tests. + * + * Unlike `MmkvAdapter`, this one does not go through the JSON codec — + * it stores value references directly in a `Map`. That's deliberate: + * tests that want to verify codec round-trips should do so against + * `MmkvAdapter` + `InMemoryMmkv`, which exercises the real serialization + * path. Tests of the collection layer and repositories don't care how + * persistence works; they want fast, allocation-cheap storage. + * + * If a test wants to verify the adapter *and* the codec together, pass + * `{ roundTripThroughJson: true }` and the adapter will serialize + * through the Decimal codec the same way MmkvAdapter does. + */ +export class MemoryAdapter implements PersistentAdapter { + readonly name: string + readonly schemaVersion: number + private entries = new Map() + private readonly roundTripThroughJson: boolean + + constructor(options: { + name: string + schemaVersion?: number + roundTripThroughJson?: boolean + }) { + this.name = options.name + this.schemaVersion = options.schemaVersion ?? 1 + this.roundTripThroughJson = options.roundTripThroughJson ?? false + } + + hydrate(): Map { + return new Map(this.entries) + } + + put(key: CollectionKey, value: TValue): void { + this.entries.set(key, this.clone(value)) + } + + putMany( + entries: ReadonlyArray, + ): void { + for (const [key, value] of entries) { + this.entries.set(key, this.clone(value)) + } + } + + delete(key: CollectionKey): void { + this.entries.delete(key) + } + + deleteMany(keys: readonly CollectionKey[]): void { + for (const key of keys) { + this.entries.delete(key) + } + } + + deleteAll(): void { + this.entries.clear() + } + + /** Test helper — inspect the underlying map. */ + snapshot(): ReadonlyMap { + return this.entries + } + + private clone(value: TValue): TValue { + if (!this.roundTripThroughJson) return value + return decode(encode(value)) + } +} diff --git a/packages/database/src/collections/adapter/mmkv-adapter.ts b/packages/database/src/collections/adapter/mmkv-adapter.ts new file mode 100644 index 000000000..3ab62b947 --- /dev/null +++ b/packages/database/src/collections/adapter/mmkv-adapter.ts @@ -0,0 +1,174 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { decode, encode } from './decimal-codec' +import type { + CollectionKey, + MmkvLike, + PersistentAdapter, +} from './types' + +/** + * Per-collection MMKV key namespace: + * + * tdb::: → serialized JSON value + * tdb_version: → number (last seen schemaVersion) + * + * The `tdb` (TanStack DB) prefix keeps us disjoint from Zustand's persist + * keys and any other future MMKV consumers. The schema version is baked + * into the value key so a bump naturally orphans the old keys, which we + * then sweep in `reconcileSchemaVersion`. + */ +const KEY_PREFIX = 'tdb' +const VERSION_KEY_PREFIX = 'tdb_version' + +const SEPARATOR = ':' + +function dataKey(name: string, version: number, key: CollectionKey): string { + return `${KEY_PREFIX}${SEPARATOR}${name}${SEPARATOR}${version}${SEPARATOR}${key}` +} + +function dataKeyPrefix(name: string, version: number): string { + return `${KEY_PREFIX}${SEPARATOR}${name}${SEPARATOR}${version}${SEPARATOR}` +} + +function collectionKeyPrefix(name: string): string { + return `${KEY_PREFIX}${SEPARATOR}${name}${SEPARATOR}` +} + +function versionKey(name: string): string { + return `${VERSION_KEY_PREFIX}${SEPARATOR}${name}` +} + +function parseDataKey( + name: string, + version: number, + storedKey: string, +): CollectionKey | null { + const prefix = dataKeyPrefix(name, version) + if (!storedKey.startsWith(prefix)) return null + return storedKey.slice(prefix.length) +} + +export type MmkvAdapterOptions = { + name: string + schemaVersion: number + mmkv: MmkvLike +} + +export class MmkvAdapter implements PersistentAdapter { + readonly name: string + readonly schemaVersion: number + private readonly mmkv: MmkvLike + + constructor({ name, schemaVersion, mmkv }: MmkvAdapterOptions) { + this.name = name + this.schemaVersion = schemaVersion + this.mmkv = mmkv + this.reconcileSchemaVersion() + } + + hydrate(): Map { + const out = new Map() + const storedKeys = this.mmkv.getAllKeys() + for (const storedKey of storedKeys) { + const key = parseDataKey(this.name, this.schemaVersion, storedKey) + if (key === null) continue + + const serialized = this.mmkv.getString(storedKey) + if (serialized === undefined) continue + + try { + out.set(key, decode(serialized)) + } catch (err) { + // Per-entry try/catch: never crash bootstrap on a single + // corrupt row. Drop the offending key so it does not keep + // failing on every hydrate. + this.mmkv.delete(storedKey) + logHydrationFailure(this.name, key, err) + } + } + return out + } + + put(key: CollectionKey, value: TValue): void { + this.mmkv.set( + dataKey(this.name, this.schemaVersion, key), + encode(value), + ) + } + + putMany( + entries: ReadonlyArray, + ): void { + for (const [key, value] of entries) { + this.put(key, value) + } + } + + delete(key: CollectionKey): void { + this.mmkv.delete(dataKey(this.name, this.schemaVersion, key)) + } + + deleteMany(keys: readonly CollectionKey[]): void { + for (const key of keys) { + this.delete(key) + } + } + + deleteAll(): void { + const prefix = collectionKeyPrefix(this.name) + const storedKeys = this.mmkv.getAllKeys() + for (const storedKey of storedKeys) { + if (storedKey.startsWith(prefix)) { + this.mmkv.delete(storedKey) + } + } + } + + /** + * Compare the stored schemaVersion to the version this adapter was + * constructed with. If they differ (or there is no stored version), + * wipe every entry for this collection — persisted rows are always + * derivable from the network, so drop-and-rebuild is the safe and + * simple replacement for formal migrations. + */ + private reconcileSchemaVersion(): void { + const key = versionKey(this.name) + const stored = this.mmkv.getString(key) + const storedVersion = stored === undefined ? null : Number(stored) + if (storedVersion === this.schemaVersion) return + + // Wipe every entry for this collection, regardless of schemaVersion + // encoded in the key — we don't want orphaned old-version rows + // lingering forever. + const prefix = collectionKeyPrefix(this.name) + for (const storedKey of this.mmkv.getAllKeys()) { + if (storedKey.startsWith(prefix)) { + this.mmkv.delete(storedKey) + } + } + this.mmkv.set(key, String(this.schemaVersion)) + } +} + +function logHydrationFailure( + collection: string, + key: CollectionKey, + err: unknown, +): void { + // eslint-disable-next-line no-console + console.warn( + `[collections] Dropping corrupt row during hydrate: collection=${collection} key=${key}`, + err, + ) +} diff --git a/packages/database/src/collections/adapter/types.ts b/packages/database/src/collections/adapter/types.ts new file mode 100644 index 000000000..eada82928 --- /dev/null +++ b/packages/database/src/collections/adapter/types.ts @@ -0,0 +1,56 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export type CollectionKey = string + +/** + * Structural subset of the `react-native-mmkv` MMKV class that the + * persistence adapter actually uses. Declared here so the database + * package does not have to take a direct dependency on `react-native-mmkv` + * and can be driven by an in-memory stub in tests. + */ +export interface MmkvLike { + set(key: string, value: string): void + getString(key: string): string | undefined + delete(key: string): void + getAllKeys(): string[] +} + +/** + * Durable, synchronous key-value storage for one TanStack DB collection. + * + * Each collection gets its own adapter instance with a distinct name. + * Values are serialized/deserialized through a Decimal-aware JSON codec + * (see decimal-codec.ts), so `Decimal` fields round-trip without loss. + */ +export interface PersistentAdapter { + readonly name: string + readonly schemaVersion: number + + /** + * Load every persisted entry for this collection into memory. + * + * Called once at bootstrap. Malformed entries are discarded (and the + * underlying key deleted) rather than throwing — one corrupt row must + * never crash app startup. + */ + hydrate(): Map + + put(key: CollectionKey, value: TValue): void + putMany(entries: ReadonlyArray): void + + delete(key: CollectionKey): void + deleteMany(keys: readonly CollectionKey[]): void + + /** Wipe every persisted entry for this collection. */ + deleteAll(): void +} diff --git a/packages/database/src/collections/collection.ts b/packages/database/src/collections/collection.ts new file mode 100644 index 000000000..2db783eca --- /dev/null +++ b/packages/database/src/collections/collection.ts @@ -0,0 +1,234 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { CollectionKey, PersistentAdapter } from './adapter' + +/** + * A reactive, in-memory collection backed by a durable `PersistentAdapter`. + * + * Design intent: this layer is API-shaped to be a drop-in replacement for + * an eventual `@tanstack/db` LocalOnly collection. The public surface — + * `get` / `has` / `state` / `insert` / `update` / `upsert` / `delete` / + * `transact` / `subscribe` — mirrors TanStack DB's semantics so that once + * the upstream beta stabilizes, the swap is localized to `registry.ts` + * and nothing else. Everything outside the registry talks to this type. + * + * Properties it guarantees today: + * + * - Reads are synchronous `Map` lookups over the in-memory state. + * - Writes are synchronous: adapter flush happens inside `insert` / + * `delete`. `transact` batches notifications (and gives the adapter + * a chance to batch IO via `putMany` / `deleteMany`) but still flushes + * per-op on commit — the adapter is free to buffer internally if it + * wants. + * - Subscribers are notified at most once per top-level mutation or + * per `transact` commit. During a transaction, intermediate writes + * do not fire listeners. + * - `hydrate()` is called once at construction; thereafter the + * in-memory state is the source of truth for reads. + */ +export class Collection { + readonly name: string + private readonly adapter: PersistentAdapter + private readonly getKey: (value: TValue) => CollectionKey + + private entries: Map + private readonly listeners = new Set<() => void>() + + private transactDepth = 0 + private pendingNotify = false + + // Buffered writes inside a transaction. Flushed to the adapter on + // commit so we can use `putMany` / `deleteMany` for batching. + private pendingPuts: Array | null = null + private pendingDeletes: CollectionKey[] | null = null + + constructor(options: { + name: string + adapter: PersistentAdapter + getKey: (value: TValue) => CollectionKey + }) { + this.name = options.name + this.adapter = options.adapter + this.getKey = options.getKey + this.entries = options.adapter.hydrate() + } + + // --- Reads ----------------------------------------------------------- + + get(key: CollectionKey): TValue | undefined { + return this.entries.get(key) + } + + has(key: CollectionKey): boolean { + return this.entries.has(key) + } + + get size(): number { + return this.entries.size + } + + /** Current state as a read-only map. Stable reference until the next write. */ + get state(): ReadonlyMap { + return this.entries + } + + /** Convenience: every value as an array. Allocates. */ + values(): TValue[] { + return [...this.entries.values()] + } + + /** + * Iterate every value whose key starts with `prefix`. Used by the + * domain query patterns that previously relied on SQL prefix matching + * (`WHERE network = ? AND address = ?` on composite keys). + * + * Implemented as a linear scan over the collection — acceptable at + * the collection sizes we persist (100s of rows per account for the + * hot collections; 1000s for transactions, addressed separately via + * denormalized indexes in the consuming repositories). + */ + *entriesWithPrefix( + prefix: string, + ): IterableIterator { + for (const entry of this.entries) { + if (entry[0].startsWith(prefix)) yield entry + } + } + + // --- Writes ---------------------------------------------------------- + + /** + * Insert or update by key. Idempotent — matches the upsert semantics + * the SQLite repositories used throughout. + */ + upsert(value: TValue): void { + const key = this.getKey(value) + this.entries.set(key, value) + this.queuePut(key, value) + this.notify() + } + + upsertMany(values: readonly TValue[]): void { + if (values.length === 0) return + this.transact(() => { + for (const value of values) this.upsert(value) + }) + } + + delete(key: CollectionKey): boolean { + const existed = this.entries.delete(key) + if (existed) { + this.queueDelete(key) + this.notify() + } + return existed + } + + deleteWhere(predicate: (value: TValue, key: CollectionKey) => boolean): number { + const toDelete: CollectionKey[] = [] + for (const [key, value] of this.entries) { + if (predicate(value, key)) toDelete.push(key) + } + if (toDelete.length === 0) return 0 + this.transact(() => { + for (const key of toDelete) this.delete(key) + }) + return toDelete.length + } + + /** + * Run `fn` as a single logical mutation. Listeners are notified once + * at commit (instead of once per intermediate write), and buffered + * writes are flushed to the adapter via `putMany` / `deleteMany`. + * + * Re-entrant: nested `transact` calls collapse into the outermost + * commit. + */ + transact(fn: () => void): void { + if (this.transactDepth === 0) { + this.pendingPuts = [] + this.pendingDeletes = [] + } + this.transactDepth += 1 + try { + fn() + } finally { + this.transactDepth -= 1 + if (this.transactDepth === 0) { + this.commitPending() + if (this.pendingNotify) { + this.pendingNotify = false + this.emit() + } + } + } + } + + /** Reset to an empty collection, synchronously flushing to the adapter. */ + clear(): void { + if (this.entries.size === 0) return + this.entries = new Map() + this.adapter.deleteAll() + this.notify() + } + + // --- Subscriptions --------------------------------------------------- + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + // --- Internals ------------------------------------------------------- + + private queuePut(key: CollectionKey, value: TValue): void { + if (this.pendingPuts !== null) { + this.pendingPuts.push([key, value]) + } else { + this.adapter.put(key, value) + } + } + + private queueDelete(key: CollectionKey): void { + if (this.pendingDeletes !== null) { + this.pendingDeletes.push(key) + } else { + this.adapter.delete(key) + } + } + + private commitPending(): void { + if (this.pendingPuts !== null && this.pendingPuts.length > 0) { + this.adapter.putMany(this.pendingPuts) + } + if (this.pendingDeletes !== null && this.pendingDeletes.length > 0) { + this.adapter.deleteMany(this.pendingDeletes) + } + this.pendingPuts = null + this.pendingDeletes = null + } + + private notify(): void { + if (this.transactDepth > 0) { + this.pendingNotify = true + return + } + this.emit() + } + + private emit(): void { + for (const listener of this.listeners) listener() + } +} diff --git a/packages/database/src/collections/index.ts b/packages/database/src/collections/index.ts new file mode 100644 index 000000000..290868154 --- /dev/null +++ b/packages/database/src/collections/index.ts @@ -0,0 +1,86 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export type { + CollectionKey, + MmkvLike, + PersistentAdapter, +} from './adapter' +export { + MmkvAdapter, + MemoryAdapter, + InMemoryMmkv, + encode, + decode, + fromKeyValueStorage, + type KeyValueStorageLike, +} from './adapter' + +export { Collection } from './collection' + +export { + bootstrapCollections, + bootstrapTestCollections, + getCollections, + resetAllCollections, + resetRegistryForTest, + type BootstrapOptions, + type CollectionRegistry, +} from './registry' + +export { + NFD_CACHE_COLLECTION_NAME, + NFD_CACHE_SCHEMA_VERSION, + nfdCacheKey, + type NfdCacheRow, +} from './schemas/nfd-cache' + +export { + ASSETS_NODE_COLLECTION_NAME, + ASSETS_NODE_SCHEMA_VERSION, + ASSETS_PERA_COLLECTION_NAME, + ASSETS_PERA_SCHEMA_VERSION, + ASSET_PRICES_COLLECTION_NAME, + ASSET_PRICES_SCHEMA_VERSION, + assetsNodeKey, + assetsPeraKey, + assetPricesKey, + type AssetsNodeRow, + type AssetsPeraRow, + type AssetPriceRow as AssetPriceCollectionRow, +} from './schemas/assets' + +export { + ACCOUNT_BALANCES_COLLECTION_NAME, + ACCOUNT_BALANCES_SCHEMA_VERSION, + ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + ACCOUNT_ASSET_HOLDINGS_SCHEMA_VERSION, + accountBalancesKey, + accountAssetHoldingsKey, + accountAssetHoldingsPrefix, + type AccountBalanceRow as AccountBalanceCollectionRow, + type AccountAssetHoldingRow, +} from './schemas/accounts' + +export { + TRANSACTIONS_COLLECTION_NAME, + TRANSACTIONS_SCHEMA_VERSION, + ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + ACCOUNT_TRANSACTIONS_SCHEMA_VERSION, + transactionsKey, + accountTransactionsKey, + accountTransactionsPrefix, + type TransactionRow, + type AccountTransactionRow, +} from './schemas/transactions' + +export { useCollectionQuery } from './react' diff --git a/packages/database/src/collections/react.ts b/packages/database/src/collections/react.ts new file mode 100644 index 000000000..17b20e53b --- /dev/null +++ b/packages/database/src/collections/react.ts @@ -0,0 +1,102 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react' +import type { Collection } from './collection' + +type DependencyList = ReadonlyArray + +/** + * Subscribe a React component to a derived value of a `Collection`. + * + * `compute` runs every time the collection changes or `deps` changes. Its + * result is cached by user-supplied `isEqual` (default: `Object.is`) so + * components re-render only when the derived value actually changes. + * + * ```ts + * const row = useCollectionQuery( + * nfdCacheCollection, + * c => c.get(`${network}:${address}`), + * [network, address], + * ) + * ``` + * + * The shape (collection + compute + deps) mirrors what a TanStack DB + * `useLiveQuery` call looks like for single-result reads, so once we + * upgrade to the real upstream library the call sites keep working with + * minimal edits. + */ +export function useCollectionQuery( + collection: Collection, + compute: (collection: Collection) => TResult, + deps: DependencyList, + isEqual: (a: TResult, b: TResult) => boolean = Object.is, +): TResult { + // We combine three signals into one "version" used by useSyncExternalStore: + // + // (1) a monotonically-increasing counter that bumps whenever the + // collection notifies subscribers; + // (2) the identity of the `deps` array (so prop changes re-run + // `compute` even though the collection hasn't changed); + // (3) the identity of the collection itself. + // + // `getSnapshot` must be stable between subscribes, so we memoize its + // return value until any of those signals advances. + const tickRef = useRef(0) + const lastDepsRef = useRef(null) + const lastResultRef = useRef<{ value: TResult } | null>(null) + const lastTickRef = useRef(-1) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const depsKey = useMemo(() => deps, deps) + + const subscribe = useCallback( + (onStoreChange: () => void) => { + return collection.subscribe(() => { + tickRef.current += 1 + onStoreChange() + }) + }, + [collection], + ) + + const getSnapshot = useCallback((): TResult => { + const tick = tickRef.current + const depsChanged = lastDepsRef.current !== depsKey + if ( + !depsChanged && + lastTickRef.current === tick && + lastResultRef.current !== null + ) { + return lastResultRef.current.value + } + const next = compute(collection) + if ( + lastResultRef.current !== null && + isEqual(lastResultRef.current.value, next) + ) { + lastTickRef.current = tick + lastDepsRef.current = depsKey + return lastResultRef.current.value + } + lastResultRef.current = { value: next } + lastTickRef.current = tick + lastDepsRef.current = depsKey + return next + // `compute` and `isEqual` are intentionally not in the deps list: + // they are expected to be pure-ish closures and the result cache + // already handles staleness via `depsKey` + collection tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [collection, depsKey]) + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/packages/database/src/collections/registry.ts b/packages/database/src/collections/registry.ts new file mode 100644 index 000000000..dd26c055b --- /dev/null +++ b/packages/database/src/collections/registry.ts @@ -0,0 +1,260 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { + MemoryAdapter, + MmkvAdapter, + type MmkvLike, + type PersistentAdapter, +} from './adapter' +import { Collection } from './collection' +import { + ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + ACCOUNT_ASSET_HOLDINGS_SCHEMA_VERSION, + ACCOUNT_BALANCES_COLLECTION_NAME, + ACCOUNT_BALANCES_SCHEMA_VERSION, + accountAssetHoldingsKey, + accountBalancesKey, + type AccountAssetHoldingRow, + type AccountBalanceRow, +} from './schemas/accounts' +import { + ASSETS_NODE_COLLECTION_NAME, + ASSETS_NODE_SCHEMA_VERSION, + ASSETS_PERA_COLLECTION_NAME, + ASSETS_PERA_SCHEMA_VERSION, + ASSET_PRICES_COLLECTION_NAME, + ASSET_PRICES_SCHEMA_VERSION, + assetPricesKey, + assetsNodeKey, + assetsPeraKey, + type AssetPriceRow, + type AssetsNodeRow, + type AssetsPeraRow, +} from './schemas/assets' +import { + NFD_CACHE_COLLECTION_NAME, + NFD_CACHE_SCHEMA_VERSION, + nfdCacheKey, + type NfdCacheRow, +} from './schemas/nfd-cache' +import { + ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + ACCOUNT_TRANSACTIONS_SCHEMA_VERSION, + TRANSACTIONS_COLLECTION_NAME, + TRANSACTIONS_SCHEMA_VERSION, + accountTransactionsKey, + transactionsKey, + type AccountTransactionRow, + type TransactionRow, +} from './schemas/transactions' + +/** + * Collection registry — the single place every domain package reaches + * into to get a typed handle on its reactive collection. + * + * The registry is deliberately structured around a single, centralized + * `getCollections()` accessor so that the eventual swap to the real + * `@tanstack/db` library is localized: only this file imports the + * upstream primitives, and every consumer of the collections keeps + * talking to `Collection` as declared in `collection.ts`. + * + * Bootstrap lifecycle: + * + * 1. The mobile app calls `bootstrapCollections({ mmkv })` once at + * startup with the raw MMKV instance from the platform-rn package. + * 2. The registry instantiates one `MmkvAdapter` per collection, + * hydrates the in-memory state from MMKV, and wires each adapter + * to a new `Collection`. + * 3. Every subsequent call to `getCollections()` returns the same + * instance. Calling it before bootstrap throws. + * + * For tests, use `bootstrapTestCollections()` which skips MMKV entirely + * and uses the in-memory `MemoryAdapter`. + */ + +export type CollectionRegistry = { + nfdCache: Collection + assetsNode: Collection + assetsPera: Collection + assetPrices: Collection + accountBalances: Collection + accountAssetHoldings: Collection + transactions: Collection + accountTransactions: Collection +} + +let instance: CollectionRegistry | null = null + +export type BootstrapOptions = { + mmkv: MmkvLike +} + +export function bootstrapCollections( + options: BootstrapOptions, +): CollectionRegistry { + const { mmkv } = options + instance = { + nfdCache: new Collection({ + name: NFD_CACHE_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: NFD_CACHE_COLLECTION_NAME, + schemaVersion: NFD_CACHE_SCHEMA_VERSION, + mmkv, + }), + getKey: nfdCacheKey, + }), + assetsNode: new Collection({ + name: ASSETS_NODE_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ASSETS_NODE_COLLECTION_NAME, + schemaVersion: ASSETS_NODE_SCHEMA_VERSION, + mmkv, + }), + getKey: assetsNodeKey, + }), + assetsPera: new Collection({ + name: ASSETS_PERA_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ASSETS_PERA_COLLECTION_NAME, + schemaVersion: ASSETS_PERA_SCHEMA_VERSION, + mmkv, + }), + getKey: assetsPeraKey, + }), + assetPrices: new Collection({ + name: ASSET_PRICES_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ASSET_PRICES_COLLECTION_NAME, + schemaVersion: ASSET_PRICES_SCHEMA_VERSION, + mmkv, + }), + getKey: assetPricesKey, + }), + accountBalances: new Collection({ + name: ACCOUNT_BALANCES_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ACCOUNT_BALANCES_COLLECTION_NAME, + schemaVersion: ACCOUNT_BALANCES_SCHEMA_VERSION, + mmkv, + }), + getKey: accountBalancesKey, + }), + accountAssetHoldings: new Collection({ + name: ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + schemaVersion: ACCOUNT_ASSET_HOLDINGS_SCHEMA_VERSION, + mmkv, + }), + getKey: accountAssetHoldingsKey, + }), + transactions: new Collection({ + name: TRANSACTIONS_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: TRANSACTIONS_COLLECTION_NAME, + schemaVersion: TRANSACTIONS_SCHEMA_VERSION, + mmkv, + }), + getKey: transactionsKey, + }), + accountTransactions: new Collection({ + name: ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + adapter: new MmkvAdapter({ + name: ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + schemaVersion: ACCOUNT_TRANSACTIONS_SCHEMA_VERSION, + mmkv, + }), + getKey: accountTransactionsKey, + }), + } + return instance +} + +/** + * Test-only bootstrap. Each call returns a fresh, isolated registry + * backed by `MemoryAdapter` — use this in `beforeEach` to guarantee + * test isolation without paying for JSON roundtrips. + */ +export function bootstrapTestCollections(): CollectionRegistry { + const registry: CollectionRegistry = { + nfdCache: makeCollection({ + name: NFD_CACHE_COLLECTION_NAME, + getKey: nfdCacheKey, + }), + assetsNode: makeCollection({ + name: ASSETS_NODE_COLLECTION_NAME, + getKey: assetsNodeKey, + }), + assetsPera: makeCollection({ + name: ASSETS_PERA_COLLECTION_NAME, + getKey: assetsPeraKey, + }), + assetPrices: makeCollection({ + name: ASSET_PRICES_COLLECTION_NAME, + getKey: assetPricesKey, + }), + accountBalances: makeCollection({ + name: ACCOUNT_BALANCES_COLLECTION_NAME, + getKey: accountBalancesKey, + }), + accountAssetHoldings: makeCollection({ + name: ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + getKey: accountAssetHoldingsKey, + }), + transactions: makeCollection({ + name: TRANSACTIONS_COLLECTION_NAME, + getKey: transactionsKey, + }), + accountTransactions: makeCollection({ + name: ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + getKey: accountTransactionsKey, + }), + } + instance = registry + return registry +} + +function makeCollection(options: { + name: string + getKey: (value: TValue) => string + adapter?: PersistentAdapter +}): Collection { + return new Collection({ + name: options.name, + adapter: + options.adapter ?? new MemoryAdapter({ name: options.name }), + getKey: options.getKey, + }) +} + +export function getCollections(): CollectionRegistry { + if (instance === null) { + throw new Error( + 'Collections not initialized. Call bootstrapCollections() during app bootstrap.', + ) + } + return instance +} + +/** Wipes every persisted collection. Used by the "delete all data" flow. */ +export function resetAllCollections(): void { + const registry = getCollections() + for (const key of Object.keys(registry) as Array) { + registry[key].clear() + } +} + +/** Test helper. Drops the singleton so the next `bootstrap*` call rebuilds. */ +export function resetRegistryForTest(): void { + instance = null +} diff --git a/packages/database/src/collections/schemas/accounts.ts b/packages/database/src/collections/schemas/accounts.ts new file mode 100644 index 000000000..76dca7512 --- /dev/null +++ b/packages/database/src/collections/schemas/accounts.ts @@ -0,0 +1,87 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { Decimal } from 'decimal.js' + +/** + * Collection definitions for the two account-level tables. + * + * - `account_balances` — one row per (network, account) with + * ALGO balance, opted-in counts, auth addr. + * - `account_asset_holdings` — one row per (network, account, asset) + * with the holding amount. + * + * Keys are network-prefixed so a prefix scan yields every row for one + * network; accounts are next so a prefix scan yields every holding for + * one account on one network (used by `refreshAccountHoldings`). + */ + +// --- account_balances ------------------------------------------------------- + +export type AccountBalanceRow = { + network: string + accountAddress: string + /** ALGO balance in display units (ALGOs, not microAlgos). */ + algoBalance: Decimal + totalAssetsOptedIn: number + totalCreatedAssets: number + totalAppsOptedIn: number + /** Minimum balance in display units (ALGOs, not microAlgos). */ + minBalance: Decimal + status: string + authAddress: string | null + updatedAt: number +} + +export const ACCOUNT_BALANCES_COLLECTION_NAME = 'account_balances' +export const ACCOUNT_BALANCES_SCHEMA_VERSION = 1 + +export function accountBalancesKey(row: { + network: string + accountAddress: string +}): string { + return `${row.network}:${row.accountAddress}` +} + +// --- account_asset_holdings ------------------------------------------------- + +export type AccountAssetHoldingRow = { + network: string + accountAddress: string + assetId: Decimal + /** Amount in base units (smallest indivisible unit of the asset). */ + amount: Decimal + updatedAt: number +} + +export const ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME = 'account_asset_holdings' +export const ACCOUNT_ASSET_HOLDINGS_SCHEMA_VERSION = 1 + +export function accountAssetHoldingsKey(row: { + network: string + accountAddress: string + assetId: Decimal | string +}): string { + return `${row.network}:${row.accountAddress}:${row.assetId.toString()}` +} + +/** + * Prefix used to scan every holding for one (network, account) pair. + * Matches the composite-key SQL pattern `WHERE network = ? AND account = ?` + * that the old Drizzle implementation used. + */ +export function accountAssetHoldingsPrefix(params: { + network: string + accountAddress: string +}): string { + return `${params.network}:${params.accountAddress}:` +} diff --git a/packages/database/src/collections/schemas/assets.ts b/packages/database/src/collections/schemas/assets.ts new file mode 100644 index 000000000..b3a4b87c6 --- /dev/null +++ b/packages/database/src/collections/schemas/assets.ts @@ -0,0 +1,104 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { Decimal } from 'decimal.js' + +/** + * Collection definitions for the three asset tables. + * + * - `assets_node` — node-level asset metadata (creator, supply, unit, decimals, ...) + * - `assets_pera` — Pera-augmented metadata + device-local user fields + * (`isFavorited`, `isPriceAlertEnabled`) + * - `asset_prices` — USD prices per asset + * + * All three use `${network}:${assetId}` as the composite key, matching + * the old SQLite composite primary key. `assetId` is serialized as its + * string representation in the key (which matches its canonical form — + * Algorand asset ids are non-negative integers and `Decimal.toString()` + * preserves their exact value), while the field inside the value stays + * typed as `Decimal` so the Decimal codec round-trips it through MMKV. + */ + +// --- assets_node --- + +export type AssetsNodeRow = { + network: string + assetId: Decimal + decimals: number + creatorAddress: string + totalSupply: Decimal + name: string | null + unitName: string | null + url: string | null + metadata: string | null + updatedAt: number +} + +export const ASSETS_NODE_COLLECTION_NAME = 'assets_node' +export const ASSETS_NODE_SCHEMA_VERSION = 1 + +export function assetsNodeKey(row: { + network: string + assetId: Decimal | string +}): string { + return `${row.network}:${row.assetId.toString()}` +} + +// --- assets_pera --- + +export type AssetsPeraRow = { + network: string + assetId: Decimal + verificationTier: string + isDeleted: boolean + assetType: string | null + /** + * Serialized `PeraAssetMetadata` (JSON string). + * + * Kept as a string — not a structured field — because the + * `PeraAssetMetadata` type lives in `@perawallet/wallet-core-assets` + * and the database package must not import from it (would create a + * cycle). The repository parses/stringifies at the boundary, same + * as the old Drizzle implementation did. + */ + peraMetadataJson: string | null + updatedAt: number +} + +export const ASSETS_PERA_COLLECTION_NAME = 'assets_pera' +export const ASSETS_PERA_SCHEMA_VERSION = 1 + +export function assetsPeraKey(row: { + network: string + assetId: Decimal | string +}): string { + return `${row.network}:${row.assetId.toString()}` +} + +// --- asset_prices --- + +export type AssetPriceRow = { + network: string + assetId: Decimal + usdPrice: Decimal + updatedAt: number +} + +export const ASSET_PRICES_COLLECTION_NAME = 'asset_prices' +export const ASSET_PRICES_SCHEMA_VERSION = 1 + +export function assetPricesKey(row: { + network: string + assetId: Decimal | string +}): string { + return `${row.network}:${row.assetId.toString()}` +} diff --git a/packages/database/src/collections/schemas/nfd-cache.ts b/packages/database/src/collections/schemas/nfd-cache.ts new file mode 100644 index 000000000..baa7d6871 --- /dev/null +++ b/packages/database/src/collections/schemas/nfd-cache.ts @@ -0,0 +1,42 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +/** + * Collection definition for the NFD (Algorand Name Service) address cache. + * + * A `null` `name` represents a negative cache entry — "we looked this + * address up and it has no NFD" — and is refreshed according to `updatedAt` + * just like positive entries. + * + * Key schema: `${network}:${address}` — matches the `(address, network)` + * composite primary key the old SQLite schema used. + */ + +export type NfdCacheRow = { + network: string + address: string + /** NULL name = negative cache. */ + name: string | null + image: string | null + source: string | null + updatedAt: number +} + +export const NFD_CACHE_COLLECTION_NAME = 'nfd_cache' +export const NFD_CACHE_SCHEMA_VERSION = 1 + +export function nfdCacheKey(row: { + network: string + address: string +}): string { + return `${row.network}:${row.address}` +} diff --git a/packages/database/src/collections/schemas/transactions.ts b/packages/database/src/collections/schemas/transactions.ts new file mode 100644 index 000000000..1fa9e38fd --- /dev/null +++ b/packages/database/src/collections/schemas/transactions.ts @@ -0,0 +1,101 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import type { Decimal } from 'decimal.js' + +/** + * Collection definitions for the two transaction-history tables. + * + * - `transactions` — one row per transaction id per network, + * with the full on-chain detail payload. + * - `account_transactions` — join bridge: one row per (network, account, + * txid) with `roundTime` denormalized. + * + * The denormalized `roundTime` on the bridge table is the critical + * perf move for this migration. The hot path is "fetch the 25 most + * recent txs for (network, account), optionally older than a cursor" — + * we pay the cost once at write time (copying `roundTime` alongside the + * join row) so the read path can sort + limit without chasing into the + * `transactions` collection at all. + */ + +// --- transactions ----------------------------------------------------------- + +export type TransactionRow = { + network: string + id: string + txType: string + sender: string + receiver: string | null + confirmedRound: number + roundTime: number + fee: Decimal + groupId: string | null + amount: Decimal | null + closeTo: string | null + applicationId: Decimal | null + innerTransactionCount: number | null + /** + * JSON-serialized asset/swap/interpretedMeaning payloads. Parsed at + * the repository boundary — the database package must not depend on + * the transaction models defined in `@perawallet/wallet-core-transactions` + * (would create a cycle). + */ + assetJson: string | null + swapGroupDetailJson: string | null + interpretedMeaningJson: string | null + updatedAt: number +} + +export const TRANSACTIONS_COLLECTION_NAME = 'transactions' +export const TRANSACTIONS_SCHEMA_VERSION = 1 + +export function transactionsKey(row: { + network: string + id: string +}): string { + return `${row.network}:${row.id}` +} + +// --- account_transactions --------------------------------------------------- + +export type AccountTransactionRow = { + network: string + accountAddress: string + transactionId: string + assetId: Decimal | null + /** Denormalized from `transactions.roundTime` — see header comment. */ + roundTime: number +} + +export const ACCOUNT_TRANSACTIONS_COLLECTION_NAME = 'account_transactions' +export const ACCOUNT_TRANSACTIONS_SCHEMA_VERSION = 1 + +export function accountTransactionsKey(row: { + network: string + accountAddress: string + transactionId: string +}): string { + return `${row.network}:${row.accountAddress}:${row.transactionId}` +} + +/** + * Prefix used to scan every account_transactions row for one + * (network, account) pair. Matches the SQL `WHERE network = ? AND + * accountAddress = ?` pattern used by the pagination query. + */ +export function accountTransactionsPrefix(params: { + network: string + accountAddress: string +}): string { + return `${params.network}:${params.accountAddress}:` +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 3601947ef..853975bb0 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -20,3 +20,62 @@ export { export { runMigrations, type MigrationConfig } from './migrator' export { default as migrations } from './migrations' export { decimalColumn, decimalSum, decimalMax, decimalMin } from './columns' + +// Collections layer — reactive, MMKV-persisted collections that will +// replace the Drizzle/SQLite stack. See `./collections/registry.ts` for +// the public bootstrap API. +export { + bootstrapCollections, + bootstrapTestCollections, + getCollections, + resetAllCollections, + resetRegistryForTest, + Collection, + MmkvAdapter, + MemoryAdapter, + InMemoryMmkv, + encode, + decode, + useCollectionQuery, + fromKeyValueStorage, + type KeyValueStorageLike, + NFD_CACHE_COLLECTION_NAME, + NFD_CACHE_SCHEMA_VERSION, + nfdCacheKey, + ASSETS_NODE_COLLECTION_NAME, + ASSETS_NODE_SCHEMA_VERSION, + ASSETS_PERA_COLLECTION_NAME, + ASSETS_PERA_SCHEMA_VERSION, + ASSET_PRICES_COLLECTION_NAME, + ASSET_PRICES_SCHEMA_VERSION, + assetsNodeKey, + assetsPeraKey, + assetPricesKey, + ACCOUNT_BALANCES_COLLECTION_NAME, + ACCOUNT_BALANCES_SCHEMA_VERSION, + ACCOUNT_ASSET_HOLDINGS_COLLECTION_NAME, + ACCOUNT_ASSET_HOLDINGS_SCHEMA_VERSION, + accountBalancesKey, + accountAssetHoldingsKey, + accountAssetHoldingsPrefix, + TRANSACTIONS_COLLECTION_NAME, + TRANSACTIONS_SCHEMA_VERSION, + ACCOUNT_TRANSACTIONS_COLLECTION_NAME, + ACCOUNT_TRANSACTIONS_SCHEMA_VERSION, + transactionsKey, + accountTransactionsKey, + accountTransactionsPrefix, + type BootstrapOptions, + type CollectionRegistry, + type CollectionKey, + type MmkvLike, + type PersistentAdapter, + type NfdCacheRow, + type AssetsNodeRow, + type AssetsPeraRow, + type AssetPriceCollectionRow, + type AccountBalanceCollectionRow, + type AccountAssetHoldingRow, + type TransactionRow, + type AccountTransactionRow, +} from './collections' diff --git a/packages/database/vite.config.ts b/packages/database/vite.config.ts index 8a92ac4d9..ccd716fae 100644 --- a/packages/database/vite.config.ts +++ b/packages/database/vite.config.ts @@ -45,6 +45,8 @@ export default defineConfig({ 'drizzle-orm/sqlite-core', 'drizzle-orm/better-sqlite3', 'better-sqlite3', + 'decimal.js', + 'react', '@perawallet/wallet-extension-platform', '@perawallet/wallet-extension-provider', ], diff --git a/packages/nfd/src/db/__tests__/repository.spec.ts b/packages/nfd/src/db/__tests__/repository.spec.ts index 5a69ca220..222df510b 100644 --- a/packages/nfd/src/db/__tests__/repository.spec.ts +++ b/packages/nfd/src/db/__tests__/repository.spec.ts @@ -12,11 +12,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { - runMigrations, - migrations, - type Database, + bootstrapTestCollections, + resetRegistryForTest, + type CollectionRegistry, } from '@perawallet/wallet-core-database' -import { createTestDatabase } from '@perawallet/wallet-core-database/test-utils' import { upsertNfdEntries, getNfdByAddress, @@ -29,25 +28,21 @@ const ADDR_B = 'B'.repeat(58) const ADDR_C = 'C'.repeat(58) describe('nfd repository', () => { - let db: Database - let teardown: () => void - - beforeEach(async () => { - const result = createTestDatabase() - db = result.db - teardown = result.teardown - await runMigrations(db, migrations) + let registry: CollectionRegistry + + beforeEach(() => { + registry = bootstrapTestCollections() }) afterEach(() => { - teardown() + resetRegistryForTest() vi.useRealTimers() }) describe('upsertNfdEntries + getNfdByAddress', () => { it('persists positive entries and reads them back', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -62,7 +57,7 @@ describe('nfd repository', () => { }) const row = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) @@ -75,13 +70,13 @@ describe('nfd repository', () => { it('persists negative entries (no NFD) as cached null', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [{ address: ADDR_A, name: null }], }) const row = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) @@ -92,7 +87,7 @@ describe('nfd repository', () => { it('returns null for unknown addresses', async () => { const row = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) @@ -103,7 +98,7 @@ describe('nfd repository', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -117,14 +112,14 @@ describe('nfd repository', () => { ], }) const first = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) vi.setSystemTime(new Date('2026-01-02T00:00:00Z')) await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -138,7 +133,7 @@ describe('nfd repository', () => { ], }) const second = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) @@ -149,7 +144,7 @@ describe('nfd repository', () => { it('isolates entries per network', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -163,7 +158,7 @@ describe('nfd repository', () => { ], }) await upsertNfdEntries({ - db, + registry, network: 'testnet', entries: [ { @@ -178,12 +173,12 @@ describe('nfd repository', () => { }) const mainnetRow = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) const testnetRow = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'testnet', }) @@ -194,12 +189,12 @@ describe('nfd repository', () => { it('no-op on empty entries', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [], }) const row = await getNfdByAddress({ - db, + registry, address: ADDR_A, network: 'mainnet', }) @@ -210,7 +205,7 @@ describe('nfd repository', () => { describe('getNfdsByAddresses', () => { it('returns rows for the requested subset only', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -225,7 +220,7 @@ describe('nfd repository', () => { }) const rows = await getNfdsByAddresses({ - db, + registry, addresses: [ADDR_A, ADDR_C], network: 'mainnet', }) @@ -236,7 +231,7 @@ describe('nfd repository', () => { it('returns empty array for empty input', async () => { const rows = await getNfdsByAddresses({ - db, + registry, addresses: [], network: 'mainnet', }) @@ -247,7 +242,7 @@ describe('nfd repository', () => { describe('getStaleOrMissingAddresses', () => { it('returns missing addresses', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -258,7 +253,7 @@ describe('nfd repository', () => { }) const result = await getStaleOrMissingAddresses({ - db, + registry, addresses: [ADDR_A, ADDR_B, ADDR_C], network: 'mainnet', ttlMs: 60_000, @@ -271,7 +266,7 @@ describe('nfd repository', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -281,10 +276,9 @@ describe('nfd repository', () => { ], }) - // Advance past TTL vi.setSystemTime(new Date('2026-01-02T00:00:00Z')) const result = await getStaleOrMissingAddresses({ - db, + registry, addresses: [ADDR_A], network: 'mainnet', ttlMs: 60_000, @@ -295,7 +289,7 @@ describe('nfd repository', () => { it('skips fresh addresses regardless of positive or negative cache', async () => { await upsertNfdEntries({ - db, + registry, network: 'mainnet', entries: [ { @@ -307,7 +301,7 @@ describe('nfd repository', () => { }) const result = await getStaleOrMissingAddresses({ - db, + registry, addresses: [ADDR_A, ADDR_B], network: 'mainnet', ttlMs: 60 * 60 * 1000, diff --git a/packages/nfd/src/db/repository.ts b/packages/nfd/src/db/repository.ts index 817f99259..f6323150b 100644 --- a/packages/nfd/src/db/repository.ts +++ b/packages/nfd/src/db/repository.ts @@ -10,10 +10,13 @@ limitations under the License */ -import { and, eq, gte, inArray } from 'drizzle-orm' -import { getDatabase, type Database } from '@perawallet/wallet-core-database' +import { + getCollections, + nfdCacheKey, + type CollectionRegistry, + type NfdCacheRow as PersistedNfdCacheRow, +} from '@perawallet/wallet-core-database' import type { NfdName } from '../models' -import { NfdCacheSchema } from './schema' export type NfdCacheEntry = { address: string @@ -25,13 +28,7 @@ export type NfdCacheRow = NfdCacheEntry & { updatedAt: number } -function rowToEntry(row: { - address: string - name: string | null - image: string | null - source: string | null - updatedAt: number -}): NfdCacheRow { +function rowToEntry(row: PersistedNfdCacheRow): NfdCacheRow { return { address: row.address, name: row.name @@ -45,148 +42,105 @@ function rowToEntry(row: { } } -type UpsertNfdEntriesParams = { - db?: Database +type WithRegistry = { registry?: CollectionRegistry } + +function resolveRegistry(registry: CollectionRegistry | undefined): CollectionRegistry { + return registry ?? getCollections() +} + +type UpsertNfdEntriesParams = WithRegistry & { network: string entries: NfdCacheEntry[] } export async function upsertNfdEntries({ - db = getDatabase(), + registry, network, entries, }: UpsertNfdEntriesParams): Promise { if (entries.length === 0) return + const { nfdCache } = resolveRegistry(registry) const now = Date.now() - for (const entry of entries) { - const values = { - address: entry.address, + nfdCache.upsertMany( + entries.map(entry => ({ network, + address: entry.address, name: entry.name?.name ?? null, image: entry.name?.image ?? null, source: entry.name?.source ?? null, updatedAt: now, - } - - await db - .insert(NfdCacheSchema) - .values(values) - .onConflictDoUpdate({ - target: [NfdCacheSchema.address, NfdCacheSchema.network], - set: { - name: values.name, - image: values.image, - source: values.source, - updatedAt: now, - }, - }) - .run() - } + })), + ) } -type GetNfdByAddressParams = { - db?: Database +type GetNfdByAddressParams = WithRegistry & { address: string network: string } export async function getNfdByAddress({ - db = getDatabase(), + registry, address, network, }: GetNfdByAddressParams): Promise { - const rows = await db - .select({ - address: NfdCacheSchema.address, - name: NfdCacheSchema.name, - image: NfdCacheSchema.image, - source: NfdCacheSchema.source, - updatedAt: NfdCacheSchema.updatedAt, - }) - .from(NfdCacheSchema) - .where( - and( - eq(NfdCacheSchema.address, address), - eq(NfdCacheSchema.network, network), - ), - ) - .all() - - return rows[0] ? rowToEntry(rows[0]) : null + const { nfdCache } = resolveRegistry(registry) + const row = nfdCache.get(nfdCacheKey({ network, address })) + return row ? rowToEntry(row) : null } -type GetNfdsByAddressesParams = { - db?: Database +type GetNfdsByAddressesParams = WithRegistry & { addresses: string[] network: string } export async function getNfdsByAddresses({ - db = getDatabase(), + registry, addresses, network, }: GetNfdsByAddressesParams): Promise { if (addresses.length === 0) return [] - const rows = await db - .select({ - address: NfdCacheSchema.address, - name: NfdCacheSchema.name, - image: NfdCacheSchema.image, - source: NfdCacheSchema.source, - updatedAt: NfdCacheSchema.updatedAt, - }) - .from(NfdCacheSchema) - .where( - and( - inArray(NfdCacheSchema.address, addresses), - eq(NfdCacheSchema.network, network), - ), - ) - .all() - - return rows.map(rowToEntry) + const { nfdCache } = resolveRegistry(registry) + const results: NfdCacheRow[] = [] + for (const address of addresses) { + const row = nfdCache.get(nfdCacheKey({ network, address })) + if (row !== undefined) results.push(rowToEntry(row)) + } + return results } -type GetStaleOrMissingAddressesParams = { - db?: Database +type GetStaleOrMissingAddressesParams = WithRegistry & { addresses: string[] network: string ttlMs: number } /** - * Given a candidate set of addresses, returns those that are either not in - * the cache at all or older than `ttlMs`. Used by the syncer to skip work - * during steady-state polling. + * Given a candidate set of addresses, returns those that are either not + * in the cache at all or older than `ttlMs`. Used by the syncer to skip + * work during steady-state polling. * - * The freshness predicate is pushed into SQL so we only round-trip the - * matching addresses, not every cached row. + * Unlike the previous SQL-backed version, this runs entirely in memory: + * O(addresses.length) map lookups, no allocation beyond the result + * array. That's faster than the previous query for the common case + * where the caller hands us a short list (a single account's peers). */ export async function getStaleOrMissingAddresses({ - db = getDatabase(), + registry, addresses, network, ttlMs, }: GetStaleOrMissingAddressesParams): Promise { if (addresses.length === 0) return [] + const { nfdCache } = resolveRegistry(registry) const freshThreshold = Date.now() - ttlMs - const freshRows = await db - .select({ address: NfdCacheSchema.address }) - .from(NfdCacheSchema) - .where( - and( - inArray(NfdCacheSchema.address, addresses), - eq(NfdCacheSchema.network, network), - gte(NfdCacheSchema.updatedAt, freshThreshold), - ), - ) - .all() - - const freshSet = new Set(freshRows.map(row => row.address)) - return addresses.filter(addr => !freshSet.has(addr)) + return addresses.filter(address => { + const row = nfdCache.get(nfdCacheKey({ network, address })) + if (row === undefined) return true + return row.updatedAt < freshThreshold + }) } diff --git a/packages/transactions/src/db/__tests__/repository.spec.ts b/packages/transactions/src/db/__tests__/repository.spec.ts index 1a7b5d09a..42818b411 100644 --- a/packages/transactions/src/db/__tests__/repository.spec.ts +++ b/packages/transactions/src/db/__tests__/repository.spec.ts @@ -13,11 +13,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Decimal } from 'decimal.js' import { - runMigrations, - migrations, - type Database, + bootstrapTestCollections, + resetRegistryForTest, + type CollectionRegistry, } from '@perawallet/wallet-core-database' -import { createTestDatabase } from '@perawallet/wallet-core-database/test-utils' import type { TransactionHistoryItem } from '../../models/types' import { upsertTransactions, @@ -26,18 +25,14 @@ import { } from '../repository' describe('transaction repository', () => { - let db: Database - let teardown: () => void - - beforeEach(async () => { - const result = createTestDatabase() - db = result.db - teardown = result.teardown - await runMigrations(db, migrations) + let registry: CollectionRegistry + + beforeEach(() => { + registry = bootstrapTestCollections() }) afterEach(() => { - teardown() + resetRegistryForTest() }) const makeTx = ( @@ -63,14 +58,14 @@ describe('transaction repository', () => { it('inserts and retrieves transactions', async () => { await upsertTransactions({ - db, + registry, items: [makeTx()], accountAddress: 'ACCT1', network: 'mainnet', }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -83,20 +78,20 @@ describe('transaction repository', () => { it('upserts duplicate transaction IDs without duplicating', async () => { await upsertTransactions({ - db, + registry, items: [makeTx({ amount: new Decimal(100) })], accountAddress: 'ACCT1', network: 'mainnet', }) await upsertTransactions({ - db, + registry, items: [makeTx({ amount: new Decimal(200) })], accountAddress: 'ACCT1', network: 'mainnet', }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -114,7 +109,7 @@ describe('transaction repository', () => { } await upsertTransactions({ - db, + registry, items: [ makeTx({ id: 'TX_ASSET', asset }), makeTx({ id: 'TX_NO_ASSET' }), @@ -124,14 +119,14 @@ describe('transaction repository', () => { }) const withAsset = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', assetId: '31566704', }) const all = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -147,14 +142,14 @@ describe('transaction repository', () => { ) await upsertTransactions({ - db, + registry, items, accountAddress: 'ACCT1', network: 'mainnet', }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', limit: 3, @@ -165,7 +160,7 @@ describe('transaction repository', () => { it('orders by roundTime DESC', async () => { await upsertTransactions({ - db, + registry, items: [ makeTx({ id: 'TX_OLD', roundTime: 1700000000 }), makeTx({ id: 'TX_NEW', roundTime: 1700001000 }), @@ -176,7 +171,7 @@ describe('transaction repository', () => { }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -186,25 +181,25 @@ describe('transaction repository', () => { it('isolates transactions by network', async () => { await upsertTransactions({ - db, + registry, items: [makeTx({ id: 'TX_MAIN' })], accountAddress: 'ACCT1', network: 'mainnet', }) await upsertTransactions({ - db, + registry, items: [makeTx({ id: 'TX_TEST' })], accountAddress: 'ACCT1', network: 'testnet', }) const mainnet = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) const testnet = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'testnet', }) @@ -236,14 +231,14 @@ describe('transaction repository', () => { } await upsertTransactions({ - db, + registry, items: [makeTx({ asset, swapGroupDetail, interpretedMeaning })], accountAddress: 'ACCT1', network: 'mainnet', }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -255,7 +250,7 @@ describe('transaction repository', () => { it('supports beforeRoundTime pagination', async () => { await upsertTransactions({ - db, + registry, items: [ makeTx({ id: 'TX1', roundTime: 1000 }), makeTx({ id: 'TX2', roundTime: 2000 }), @@ -266,7 +261,7 @@ describe('transaction repository', () => { }) const result = await getTransactionHistory({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', beforeRoundTime: 2500, @@ -277,7 +272,7 @@ describe('transaction repository', () => { it('returns the latest round time for an account', async () => { await upsertTransactions({ - db, + registry, items: [ makeTx({ id: 'TX1', roundTime: 1000 }), makeTx({ id: 'TX2', roundTime: 3000 }), @@ -288,7 +283,7 @@ describe('transaction repository', () => { }) const result = await getLatestTransactionRoundTime({ - db, + registry, accountAddress: 'ACCT1', network: 'mainnet', }) @@ -298,7 +293,7 @@ describe('transaction repository', () => { it('returns null for an account with no transactions', async () => { const result = await getLatestTransactionRoundTime({ - db, + registry, accountAddress: 'UNKNOWN', network: 'mainnet', }) diff --git a/packages/transactions/src/db/repository.ts b/packages/transactions/src/db/repository.ts index e0a521099..3ca60f0dd 100644 --- a/packages/transactions/src/db/repository.ts +++ b/packages/transactions/src/db/repository.ts @@ -10,14 +10,29 @@ limitations under the License */ -import { eq, and, desc, lt, sql } from 'drizzle-orm' import { Decimal } from 'decimal.js' -import { getDatabase, type Database } from '@perawallet/wallet-core-database' +import { + accountTransactionsKey, + accountTransactionsPrefix, + getCollections, + transactionsKey, + type AccountTransactionRow, + type CollectionRegistry, + type TransactionRow, +} from '@perawallet/wallet-core-database' import type { TransactionHistoryItem } from '../models/types' -import { TransactionsSchema, AccountTransactionsSchema } from './schema' -function toDb(item: TransactionHistoryItem) { +type WithRegistry = { registry?: CollectionRegistry } + +function resolveRegistry( + registry: CollectionRegistry | undefined, +): CollectionRegistry { + return registry ?? getCollections() +} + +function toRow(item: TransactionHistoryItem, network: string): TransactionRow { return { + network, id: item.id, txType: item.txType, sender: item.sender, @@ -39,26 +54,11 @@ function toDb(item: TransactionHistoryItem) { interpretedMeaningJson: item.interpretedMeaning ? JSON.stringify(item.interpretedMeaning) : null, + updatedAt: Date.now(), } } -function fromDb(row: { - id: string - txType: string - sender: string - receiver: string | null - confirmedRound: number - roundTime: number - fee: Decimal - groupId: string | null - amount: Decimal | null - closeTo: string | null - applicationId: Decimal | null - innerTransactionCount: number | null - assetJson: string | null - swapGroupDetailJson: string | null - interpretedMeaningJson: string | null -}): TransactionHistoryItem { +function fromRow(row: TransactionRow): TransactionHistoryItem { return { id: row.id, txType: row.txType as TransactionHistoryItem['txType'], @@ -82,77 +82,86 @@ function fromDb(row: { } } -type UpsertTransactionsParams = { - db?: Database +function extractAssetIdFromAssetJson(assetJson: string | null): string | null { + if (!assetJson) return null + try { + const parsed = JSON.parse(assetJson) as { assetId?: number | string } + return parsed.assetId !== undefined ? String(parsed.assetId) : null + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Writes +// --------------------------------------------------------------------------- + +type UpsertTransactionsParams = WithRegistry & { items: TransactionHistoryItem[] accountAddress: string network: string } +/** + * Upsert a batch of transactions into both the `transactions` and + * `account_transactions` collections atomically. + * + * The sync service calls this once per sync tick per account; the + * write volume can be up to several hundred rows. Wrapping the writes + * in `transact` means: + * + * (a) subscribers see a single commit (one re-render per tick + * instead of one per row); and + * (b) the adapter flushes a batched `putMany` rather than one + * `set` per row. + */ export async function upsertTransactions({ - db = getDatabase(), + registry, items, accountAddress, network, }: UpsertTransactionsParams): Promise { if (items.length === 0) return - const now = Date.now() - - for (const item of items) { - const row = toDb(item) - - await db - .insert(TransactionsSchema) - .values({ - ...row, - network, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: TransactionsSchema.id, - set: { - txType: row.txType, - sender: row.sender, - receiver: row.receiver, - confirmedRound: row.confirmedRound, - roundTime: row.roundTime, - fee: row.fee, - groupId: row.groupId, - amount: row.amount, - closeTo: row.closeTo, - applicationId: row.applicationId, - innerTransactionCount: row.innerTransactionCount, - assetJson: row.assetJson, - swapGroupDetailJson: row.swapGroupDetailJson, - interpretedMeaningJson: row.interpretedMeaningJson, - updatedAt: now, - }, - }) - .run() - - const assetId = row.assetJson - ? (( - JSON.parse(row.assetJson) as { assetId?: number } - ).assetId?.toString() ?? null) - : null - - await db - .insert(AccountTransactionsSchema) - .values({ - accountAddress, - transactionId: item.id, - network, - assetId: assetId ? new Decimal(assetId) : null, - roundTime: item.roundTime, - }) - .onConflictDoNothing() - .run() - } + const { transactions, accountTransactions } = resolveRegistry(registry) + + transactions.transact(() => { + accountTransactions.transact(() => { + for (const item of items) { + const txRow = toRow(item, network) + transactions.upsert(txRow) + + // `account_transactions` uses `onConflictDoNothing` in + // the SQL version — the bridge row is immutable once + // written, so re-upserting the same (network, account, + // txid) tuple is a no-op. + const bridgeKey = accountTransactionsKey({ + network, + accountAddress, + transactionId: item.id, + }) + if (!accountTransactions.has(bridgeKey)) { + const assetIdRaw = extractAssetIdFromAssetJson( + txRow.assetJson, + ) + accountTransactions.upsert({ + network, + accountAddress, + transactionId: item.id, + assetId: assetIdRaw ? new Decimal(assetIdRaw) : null, + roundTime: item.roundTime, + }) + } + } + }) + }) } -type GetTransactionHistoryParams = { - db?: Database +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +type GetTransactionHistoryParams = WithRegistry & { accountAddress: string network: string assetId?: string @@ -160,94 +169,88 @@ type GetTransactionHistoryParams = { beforeRoundTime?: number } +/** + * Paginated transaction history for one (network, account). + * + * Strategy (matches §3.2 of the migration plan): scan + * `account_transactions` by prefix, filter by optional assetId and + * beforeRoundTime cursor, sort descending by `roundTime` (denormalized + * onto the bridge row so we don't need to chase into `transactions` + * at sort time), take the top `limit`, then sync-lookup the full row + * from `transactions` for each of the resulting txids. + * + * Cost: O(account_transactions for this account) for the filter, + * O(filtered-set log filtered-set) for the sort, then O(limit) for + * the final join. At the expected depth (low thousands of rows per + * account) this is a sub-millisecond operation after hydration. + */ export async function getTransactionHistory({ - db = getDatabase(), + registry, accountAddress, network, assetId, limit = 25, beforeRoundTime, }: GetTransactionHistoryParams): Promise { - const conditions = [ - eq(AccountTransactionsSchema.accountAddress, accountAddress), - eq(AccountTransactionsSchema.network, network), - ] - - if (assetId !== undefined) { - conditions.push( - eq(AccountTransactionsSchema.assetId, new Decimal(assetId)), - ) - } + const { transactions, accountTransactions } = resolveRegistry(registry) - if (beforeRoundTime !== undefined) { - conditions.push( - lt(AccountTransactionsSchema.roundTime, beforeRoundTime), - ) + const prefix = accountTransactionsPrefix({ network, accountAddress }) + const assetIdStr = assetId + + const candidates: AccountTransactionRow[] = [] + for (const [, bridge] of accountTransactions.entriesWithPrefix(prefix)) { + if ( + beforeRoundTime !== undefined && + bridge.roundTime >= beforeRoundTime + ) { + continue + } + if ( + assetIdStr !== undefined && + (bridge.assetId === null || bridge.assetId.toString() !== assetIdStr) + ) { + continue + } + candidates.push(bridge) } - const rows = await db - .select({ - id: TransactionsSchema.id, - txType: TransactionsSchema.txType, - sender: TransactionsSchema.sender, - receiver: TransactionsSchema.receiver, - confirmedRound: TransactionsSchema.confirmedRound, - roundTime: TransactionsSchema.roundTime, - fee: TransactionsSchema.fee, - groupId: TransactionsSchema.groupId, - amount: TransactionsSchema.amount, - closeTo: TransactionsSchema.closeTo, - applicationId: TransactionsSchema.applicationId, - innerTransactionCount: TransactionsSchema.innerTransactionCount, - assetJson: TransactionsSchema.assetJson, - swapGroupDetailJson: TransactionsSchema.swapGroupDetailJson, - interpretedMeaningJson: TransactionsSchema.interpretedMeaningJson, - }) - .from(AccountTransactionsSchema) - .innerJoin( - TransactionsSchema, - and( - eq( - AccountTransactionsSchema.transactionId, - TransactionsSchema.id, - ), - eq( - AccountTransactionsSchema.network, - TransactionsSchema.network, - ), - ), - ) - .where(and(...conditions)) - .orderBy(desc(AccountTransactionsSchema.roundTime)) - .limit(limit) - .all() + candidates.sort((a, b) => b.roundTime - a.roundTime) - return rows.map(fromDb) + const results: TransactionHistoryItem[] = [] + for (let i = 0; i < candidates.length && results.length < limit; i += 1) { + const bridge = candidates[i] + const txRow = transactions.get( + transactionsKey({ network, id: bridge.transactionId }), + ) + if (txRow !== undefined) results.push(fromRow(txRow)) + } + return results } -type GetLatestTransactionRoundTimeParams = { - db?: Database +type GetLatestTransactionRoundTimeParams = WithRegistry & { accountAddress: string network: string } +/** + * Sync checkpoint: the largest `roundTime` we have persisted for this + * (network, account). Used by the background sync service to know the + * cursor to fetch newer transactions from. + * + * A single linear scan over the bridge collection's prefix is fine — + * this is called once per sync tick per account, not per render. + */ export async function getLatestTransactionRoundTime({ - db = getDatabase(), + registry, accountAddress, network, }: GetLatestTransactionRoundTimeParams): Promise { - const rows = await db - .select({ - maxRoundTime: sql`MAX(${AccountTransactionsSchema.roundTime})`, - }) - .from(AccountTransactionsSchema) - .where( - and( - eq(AccountTransactionsSchema.accountAddress, accountAddress), - eq(AccountTransactionsSchema.network, network), - ), - ) - .all() + const { accountTransactions } = resolveRegistry(registry) + const prefix = accountTransactionsPrefix({ network, accountAddress }) - return rows[0]?.maxRoundTime ?? null + let max: number | null = null + for (const [, bridge] of accountTransactions.entriesWithPrefix(prefix)) { + if (max === null || bridge.roundTime > max) max = bridge.roundTime + } + return max } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeaa13681..7515fab15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,12 +9,42 @@ catalogs: '@algorandfoundation/algokit-utils': specifier: ^10.0.0-alpha.29 version: 10.0.0-alpha.40 + '@algorandfoundation/react-native-keystore': + specifier: 1.0.0-canary.5 + version: 1.0.0-canary.5 + '@algorandfoundation/wallet-provider': + specifier: 1.0.0-canary.3 + version: 1.0.0-canary.3 '@algorandfoundation/xhd-wallet-api': specifier: ^2.0.0-canary.1 version: 2.0.0-canary.1 '@babel/runtime': specifier: ^7.28.4 version: 7.28.6 + '@kubb/cli': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/core': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-client': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-msw': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-oas': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-react-query': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-ts': + specifier: ^4.5.8 + version: 4.33.2 + '@kubb/plugin-zod': + specifier: ^4.5.8 + version: 4.33.2 '@tanstack/query-async-storage-persister': specifier: ^5.90.9 version: 5.90.24 @@ -24,6 +54,9 @@ catalogs: '@tanstack/react-query-persist-client': specifier: ^5.90.9 version: 5.90.24 + '@tanstack/store': + specifier: ^0.8.1 + version: 0.8.1 '@testing-library/react': specifier: 16.3.0 version: 16.3.0 @@ -33,9 +66,30 @@ catalogs: '@vitejs/plugin-react': specifier: 5.1.0 version: 5.1.0 + '@vitest/coverage-v8': + specifier: ^4.0.16 + version: 4.0.18 + '@xstate/react': + specifier: ^6.1.0 + version: 6.1.0 + before-after-hook: + specifier: ^4.0.0 + version: 4.0.0 bip39: specifier: ^3.1.0 version: 3.1.0 + decimal.js: + specifier: ^10.6.0 + version: 10.6.0 + drizzle-orm: + specifier: ^0.44.0 + version: 0.44.7 + eslint: + specifier: 9.39.1 + version: 9.39.1 + eslint-plugin-unused-imports: + specifier: 4.3.0 + version: 4.3.0 oxfmt: specifier: ^0.41.0 version: 0.41.0 @@ -48,9 +102,18 @@ catalogs: util: specifier: ^0.12.5 version: 0.12.5 + uuid: + specifier: 13.0.0 + version: 13.0.0 + vite: + specifier: ^7.2.7 + version: 7.3.1 vitest: specifier: 4.0.16 version: 4.0.16 + xstate: + specifier: ^5.28.0 + version: 5.28.0 zod: specifier: ^4.1.12 version: 4.3.6 @@ -1337,15 +1400,24 @@ importers: '@perawallet/wallet-core-devtools': specifier: workspace:* version: link:../devtools + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/better-sqlite3': specifier: ^7.6.0 version: 7.6.13 + '@types/react': + specifier: 'catalog:' + version: 19.2.14 better-sqlite3: specifier: ^11.0.0 version: 11.10.0 drizzle-kit: specifier: ^0.30.0 version: 0.30.6 + react: + specifier: 'catalog:' + version: 19.2.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -10951,7 +11023,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.3 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-server: 55.0.6 fetch-nodeshim: 0.4.9 getenv: 2.0.0 @@ -11049,7 +11121,7 @@ snapshots: '@expo/dom-webview@55.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)': dependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) @@ -11104,7 +11176,7 @@ snapshots: dependencies: '@expo/dom-webview': 55.0.3(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) anser: 1.4.10 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) stacktrace-parser: 0.1.11 @@ -11131,7 +11203,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - bufferutil - supports-color @@ -11187,7 +11259,7 @@ snapshots: '@expo/json-file': 10.0.12 '@react-native/normalize-colors': 0.83.2 debug: 4.4.3 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.4 xml2js: 0.6.0 @@ -11208,7 +11280,7 @@ snapshots: '@expo/router-server@55.0.11(expo-constants@55.0.9(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3))(expo-font@55.0.4(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(expo-server@55.0.6)(expo@55.0.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: debug: 4.4.3 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-constants: 55.0.9(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) expo-font: 55.0.4(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) expo-server: 55.0.6 @@ -12271,7 +12343,7 @@ snapshots: react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) optionalDependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - '@react-native-async-storage/async-storage' @@ -12280,13 +12352,13 @@ snapshots: '@react-native-firebase/app': 23.8.6(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) stacktrace-js: 2.0.2 optionalDependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) '@react-native-firebase/messaging@23.8.6(@react-native-firebase/app@23.8.6(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(expo@55.0.8)': dependencies: '@react-native-firebase/app': 23.8.6(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0) optionalDependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) '@react-native-firebase/remote-config@23.8.6(@react-native-firebase/analytics@23.8.6(@react-native-firebase/app@23.8.6(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)))(@react-native-firebase/app@23.8.6(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))': dependencies: @@ -13504,7 +13576,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.6 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -14332,7 +14404,7 @@ snapshots: expo-asset@55.0.10(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3): dependencies: '@expo/image-utils': 0.8.12 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-constants: 55.0.9(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) @@ -14350,7 +14422,7 @@ snapshots: expo-build-properties@55.0.10(expo@55.0.8): dependencies: '@expo/schema-utils': 55.0.2 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) resolve-from: 5.0.0 semver: 7.7.4 @@ -14364,7 +14436,7 @@ snapshots: dependencies: '@expo/config': 55.0.10(typescript@5.9.3) '@expo/env': 2.1.1 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) transitivePeerDependencies: - supports-color @@ -14413,12 +14485,12 @@ snapshots: expo-file-system@55.0.12(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)): dependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) expo-font@55.0.4(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) fontfaceobserver: 2.3.0 react: 19.2.0 react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0) @@ -14440,7 +14512,7 @@ snapshots: expo-keep-awake@55.0.4(expo@55.0.8)(react@19.2.0): dependencies: - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react: 19.2.0 expo-linear-gradient@55.0.9(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): @@ -14504,7 +14576,7 @@ snapshots: expo-sqlite@14.0.6(patch_hash=0a5bc89131bb04637237f25fbb401548193ee15136b17f4867d55b77b12710dd)(expo@55.0.8): dependencies: '@expo/websql': 1.0.1 - expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo: 55.0.8(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) expo-status-bar@55.0.4(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0): dependencies: