diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c717a959..c6818a29 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -30,9 +30,12 @@ "dependencies": { "@walletconnect/sign-client": "^2.23.9", "expo": "^49.0.23", + "expo-background-fetch": "~11.3.0", "expo-router": "^2.0.15", "expo-notifications": "~0.20.1", "expo-secure-store": "^12.0.0", + "expo-sqlite": "~11.3.3", + "expo-task-manager": "~11.3.0", "react-native-reanimated": "^4.4.0", "react": "^18.2.0", "react-native": "^0.72.0", diff --git a/apps/mobile/src/db/MemoryPostStore.ts b/apps/mobile/src/db/MemoryPostStore.ts new file mode 100644 index 00000000..3f23710e --- /dev/null +++ b/apps/mobile/src/db/MemoryPostStore.ts @@ -0,0 +1,91 @@ +import type { FeedCursor, PendingPost, PendingStatus, PostRecord, PostStore } from "./types"; + +/** + * In-memory implementation of {@link PostStore}. + * + * Used both as a graceful fallback when a native SQLite module is unavailable + * (e.g. on web or during tests) and as the unit-test backend for the + * repository and sync logic. Behaviour is intentionally kept identical to the + * SQLite store so tests are meaningful. + */ +export class MemoryPostStore implements PostStore { + private posts = new Map(); + private pending = new Map(); + private cursors = new Map(); + + async init(): Promise { + // Nothing to migrate for the in-memory backend. + } + + async upsertPosts(posts: PostRecord[]): Promise { + for (const post of posts) { + this.posts.set(post.id, { ...post }); + } + } + + async getPage(offset: number, limit: number): Promise { + return [...this.posts.values()] + .sort((a, b) => b.timestamp - a.timestamp || a.id.localeCompare(b.id)) + .slice(offset, offset + limit) + .map((post) => ({ ...post })); + } + + async findMatchingPost( + author: string, + content: string, + timestamp: number, + windowSeconds: number + ): Promise { + for (const post of this.posts.values()) { + if ( + post.author === author && + post.content === content && + Math.abs(post.timestamp - timestamp) <= windowSeconds + ) { + return { ...post }; + } + } + return null; + } + + async insertPending(post: PendingPost): Promise { + this.pending.set(post.local_id, { ...post }); + } + + async getPending(): Promise { + return [...this.pending.values()] + .sort((a, b) => a.created_at - b.created_at) + .map((post) => ({ ...post })); + } + + async getPendingById(localId: string): Promise { + const found = this.pending.get(localId); + return found ? { ...found } : null; + } + + async updatePendingStatus( + localId: string, + status: PendingStatus, + error: string | null + ): Promise { + const existing = this.pending.get(localId); + if (!existing) return; + this.pending.set(localId, { ...existing, status, error }); + } + + async removePending(localId: string): Promise { + this.pending.delete(localId); + } + + async getCursor(feedType: string): Promise { + return this.cursors.get(feedType)?.last_cursor ?? null; + } + + async setCursor(feedType: string, cursor: string | null): Promise { + this.cursors.set(feedType, { + feed_type: feedType, + last_cursor: cursor, + updated_at: Math.floor(Date.now() / 1000), + }); + } +} diff --git a/apps/mobile/src/db/PostRepository.ts b/apps/mobile/src/db/PostRepository.ts new file mode 100644 index 00000000..38a78486 --- /dev/null +++ b/apps/mobile/src/db/PostRepository.ts @@ -0,0 +1,128 @@ +import { MemoryPostStore } from "./MemoryPostStore"; +import { openLinkoraDatabase } from "./database"; +import { SqlitePostStore } from "./SqlitePostStore"; +import type { PendingPost, PostRecord, PostStore } from "./types"; + +/** Window (24h) within which a stale pending post is matched to an indexer post. */ +export const CONFLICT_WINDOW_SECONDS = 24 * 60 * 60; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +/** + * High-level persistence API for the offline-first feed. + * + * Wraps a {@link PostStore} backend (SQLite in production, in-memory as a + * fallback) and exposes the operations the feed and sync layers need. + */ +export class PostRepository { + constructor(private readonly store: PostStore) {} + + /** + * Builds a repository backed by SQLite when available, otherwise falls back + * to an in-memory store so the app still functions (e.g. on web or in tests). + */ + static async create(): Promise { + const db = await openLinkoraDatabase(); + const store: PostStore = db ? new SqlitePostStore(db) : new MemoryPostStore(); + await store.init(); + return new PostRepository(store); + } + + init(): Promise { + return this.store.init(); + } + + /** Upserts confirmed posts coming from the indexer. */ + upsert(posts: PostRecord[]): Promise { + return this.store.upsertPosts(posts); + } + + /** Reads a page of confirmed posts ordered newest first. */ + getPage(offset: number, limit: number): Promise { + return this.store.getPage(offset, limit); + } + + /** Lists locally created posts awaiting confirmation. */ + getPending(): Promise { + return this.store.getPending(); + } + + getPendingById(localId: string): Promise { + return this.store.getPendingById(localId); + } + + /** Inserts a new optimistic (pending) post and returns it. */ + async addPending(localId: string, content: string): Promise { + const pending: PendingPost = { + local_id: localId, + content, + created_at: nowSeconds(), + status: "pending", + error: null, + }; + await this.store.insertPending(pending); + return pending; + } + + /** Marks a pending post as actively submitting. */ + async markSyncing(localId: string): Promise { + await this.store.updatePendingStatus(localId, "syncing", null); + } + + /** + * Promotes a confirmed pending post into the `posts` table under its + * contract-assigned id and removes it from `pending_posts`. + */ + async markSynced( + localId: string, + contractId: string, + author: string + ): Promise { + const pending = await this.store.getPendingById(localId); + if (!pending) { + await this.store.removePending(localId); + return null; + } + const record: PostRecord = { + id: contractId, + author, + content: pending.content, + tip_total: "0", + like_count: 0, + timestamp: pending.created_at, + synced_at: nowSeconds(), + }; + await this.store.upsertPosts([record]); + await this.store.removePending(localId); + return record; + } + + /** Marks a pending post as failed so the UI can offer a retry. */ + async markFailed(localId: string, error: string): Promise { + await this.store.updatePendingStatus(localId, "failed", error); + } + + /** + * Idempotency check: returns the matching confirmed post if the indexer + * already contains the post represented by `pending` (same author + content + * within the conflict window), otherwise `null`. + */ + findConfirmedMatch(author: string, pending: PendingPost): Promise { + return this.store.findMatchingPost( + author, + pending.content, + pending.created_at, + CONFLICT_WINDOW_SECONDS + ); + } + + getCursor(feedType: string): Promise { + return this.store.getCursor(feedType); + } + + setCursor(feedType: string, cursor: string | null): Promise { + return this.store.setCursor(feedType, cursor); + } +} diff --git a/apps/mobile/src/db/SqlitePostStore.ts b/apps/mobile/src/db/SqlitePostStore.ts new file mode 100644 index 00000000..58db9555 --- /dev/null +++ b/apps/mobile/src/db/SqlitePostStore.ts @@ -0,0 +1,133 @@ +import type { SqlDatabase } from "./database"; +import type { FeedCursor, PendingPost, PendingStatus, PostRecord, PostStore } from "./types"; + +/** + * SQLite-backed implementation of {@link PostStore}. + * + * Holds all of the SQL for the offline cache. The logic mirrors + * {@link ./MemoryPostStore.MemoryPostStore} exactly so behaviour is consistent + * regardless of which backend is active at runtime. + */ +export class SqlitePostStore implements PostStore { + constructor(private readonly db: SqlDatabase) {} + + async init(): Promise { + // Migrations are applied by `openLinkoraDatabase`; nothing else to do. + } + + async upsertPosts(posts: PostRecord[]): Promise { + for (const post of posts) { + await this.db.runAsync( + `INSERT INTO posts (id, author, content, tip_total, like_count, timestamp, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + author = excluded.author, + content = excluded.content, + tip_total = excluded.tip_total, + like_count = excluded.like_count, + timestamp = excluded.timestamp, + synced_at = excluded.synced_at;`, + [ + post.id, + post.author, + post.content, + post.tip_total, + post.like_count, + post.timestamp, + post.synced_at, + ] + ); + } + } + + async getPage(offset: number, limit: number): Promise { + return this.db.getAllAsync( + `SELECT id, author, content, tip_total, like_count, timestamp, synced_at + FROM posts + ORDER BY timestamp DESC, id ASC + LIMIT ? OFFSET ?;`, + [limit, offset] + ); + } + + async findMatchingPost( + author: string, + content: string, + timestamp: number, + windowSeconds: number + ): Promise { + return this.db.getFirstAsync( + `SELECT id, author, content, tip_total, like_count, timestamp, synced_at + FROM posts + WHERE author = ? AND content = ? AND ABS(timestamp - ?) <= ? + LIMIT 1;`, + [author, content, timestamp, windowSeconds] + ); + } + + async insertPending(post: PendingPost): Promise { + await this.db.runAsync( + `INSERT INTO pending_posts (local_id, content, created_at, status, error) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(local_id) DO UPDATE SET + content = excluded.content, + created_at = excluded.created_at, + status = excluded.status, + error = excluded.error;`, + [post.local_id, post.content, post.created_at, post.status, post.error] + ); + } + + async getPending(): Promise { + return this.db.getAllAsync( + `SELECT local_id, content, created_at, status, error + FROM pending_posts + ORDER BY created_at ASC;` + ); + } + + async getPendingById(localId: string): Promise { + return this.db.getFirstAsync( + `SELECT local_id, content, created_at, status, error + FROM pending_posts + WHERE local_id = ? + LIMIT 1;`, + [localId] + ); + } + + async updatePendingStatus( + localId: string, + status: PendingStatus, + error: string | null + ): Promise { + await this.db.runAsync(`UPDATE pending_posts SET status = ?, error = ? WHERE local_id = ?;`, [ + status, + error, + localId, + ]); + } + + async removePending(localId: string): Promise { + await this.db.runAsync(`DELETE FROM pending_posts WHERE local_id = ?;`, [localId]); + } + + async getCursor(feedType: string): Promise { + const row = await this.db.getFirstAsync( + `SELECT feed_type, last_cursor, updated_at FROM feed_cursor WHERE feed_type = ? LIMIT 1;`, + [feedType] + ); + return row?.last_cursor ?? null; + } + + async setCursor(feedType: string, cursor: string | null): Promise { + await this.db.runAsync( + `INSERT INTO feed_cursor (feed_type, last_cursor, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(feed_type) DO UPDATE SET + last_cursor = excluded.last_cursor, + updated_at = excluded.updated_at;`, + [feedType, cursor, Math.floor(Date.now() / 1000)] + ); + } +} diff --git a/apps/mobile/src/db/__tests__/PostRepository.test.ts b/apps/mobile/src/db/__tests__/PostRepository.test.ts new file mode 100644 index 00000000..9348802f --- /dev/null +++ b/apps/mobile/src/db/__tests__/PostRepository.test.ts @@ -0,0 +1,111 @@ +import { MemoryPostStore } from "../MemoryPostStore"; +import { PostRepository } from "../PostRepository"; +import type { PostRecord } from "../types"; + +const AUTHOR = "GABCD1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function makePost(id: string, timestamp: number, content = `post ${id}`): PostRecord { + return { + id, + author: AUTHOR, + content, + tip_total: "0", + like_count: 0, + timestamp, + synced_at: timestamp, + }; +} + +function newRepo(): PostRepository { + return new PostRepository(new MemoryPostStore()); +} + +describe("PostRepository", () => { + it("upserts and pages posts newest-first", async () => { + const repo = newRepo(); + await repo.upsert([makePost("a", 100), makePost("b", 300), makePost("c", 200)]); + + const page = await repo.getPage(0, 10); + expect(page.map((p) => p.id)).toEqual(["b", "c", "a"]); + }); + + it("upsert overwrites an existing post by id", async () => { + const repo = newRepo(); + await repo.upsert([makePost("a", 100, "first")]); + await repo.upsert([{ ...makePost("a", 100, "edited"), like_count: 9 }]); + + const page = await repo.getPage(0, 10); + expect(page).toHaveLength(1); + expect(page[0].content).toBe("edited"); + expect(page[0].like_count).toBe(9); + }); + + it("supports offset/limit pagination", async () => { + const repo = newRepo(); + await repo.upsert([makePost("a", 100), makePost("b", 200), makePost("c", 300)]); + + expect((await repo.getPage(0, 2)).map((p) => p.id)).toEqual(["c", "b"]); + expect((await repo.getPage(2, 2)).map((p) => p.id)).toEqual(["a"]); + }); + + it("adds pending posts and lists them", async () => { + const repo = newRepo(); + const pending = await repo.addPending("local-1", "hello world"); + + expect(pending.status).toBe("pending"); + expect(await repo.getPending()).toHaveLength(1); + }); + + it("markSynced moves a pending post into the posts table", async () => { + const repo = newRepo(); + await repo.addPending("local-1", "hello world"); + + const record = await repo.markSynced("local-1", "chain-42", AUTHOR); + + expect(record?.id).toBe("chain-42"); + expect(await repo.getPending()).toHaveLength(0); + const page = await repo.getPage(0, 10); + expect(page).toHaveLength(1); + expect(page[0].id).toBe("chain-42"); + expect(page[0].content).toBe("hello world"); + }); + + it("markFailed records the error and keeps the pending post for retry", async () => { + const repo = newRepo(); + await repo.addPending("local-1", "hello world"); + + await repo.markFailed("local-1", "network error"); + + const [pending] = await repo.getPending(); + expect(pending.status).toBe("failed"); + expect(pending.error).toBe("network error"); + }); + + it("findConfirmedMatch detects an already-confirmed post within the window", async () => { + const repo = newRepo(); + const pending = await repo.addPending("local-1", "duplicate content"); + await repo.upsert([ + { ...makePost("chain-1", pending.created_at), content: "duplicate content" }, + ]); + + const match = await repo.findConfirmedMatch(AUTHOR, pending); + expect(match?.id).toBe("chain-1"); + }); + + it("findConfirmedMatch ignores posts outside the conflict window", async () => { + const repo = newRepo(); + const pending = await repo.addPending("local-1", "duplicate content"); + await repo.upsert([ + { ...makePost("chain-1", pending.created_at + 48 * 3600), content: "duplicate content" }, + ]); + + expect(await repo.findConfirmedMatch(AUTHOR, pending)).toBeNull(); + }); + + it("persists and reads the feed cursor", async () => { + const repo = newRepo(); + expect(await repo.getCursor("home")).toBeNull(); + await repo.setCursor("home", "cursor-123"); + expect(await repo.getCursor("home")).toBe("cursor-123"); + }); +}); diff --git a/apps/mobile/src/db/__tests__/contentHash.test.ts b/apps/mobile/src/db/__tests__/contentHash.test.ts new file mode 100644 index 00000000..ef67254b --- /dev/null +++ b/apps/mobile/src/db/__tests__/contentHash.test.ts @@ -0,0 +1,19 @@ +import { contentHash } from "../contentHash"; + +describe("contentHash", () => { + it("is deterministic for the same author and content", () => { + expect(contentHash("GAUTHOR", "hello")).toBe(contentHash("GAUTHOR", "hello")); + }); + + it("differs when content differs", () => { + expect(contentHash("GAUTHOR", "hello")).not.toBe(contentHash("GAUTHOR", "world")); + }); + + it("differs when author differs", () => { + expect(contentHash("GAUTHOR1", "hello")).not.toBe(contentHash("GAUTHOR2", "hello")); + }); + + it("produces an 8-char hex string", () => { + expect(contentHash("GAUTHOR", "hello")).toMatch(/^[0-9a-f]{8}$/); + }); +}); diff --git a/apps/mobile/src/db/contentHash.ts b/apps/mobile/src/db/contentHash.ts new file mode 100644 index 00000000..9db76227 Binary files /dev/null and b/apps/mobile/src/db/contentHash.ts differ diff --git a/apps/mobile/src/db/database.ts b/apps/mobile/src/db/database.ts new file mode 100644 index 00000000..766c367c --- /dev/null +++ b/apps/mobile/src/db/database.ts @@ -0,0 +1,125 @@ +import { DATABASE_NAME, MIGRATIONS } from "./schema"; + +/** + * Minimal async SQL driver interface used by {@link ./SqlitePostStore}. + * + * It mirrors the promise-based surface of modern `expo-sqlite` while remaining + * decoupled from it, so the store can be exercised against any driver. + */ +export interface SqlDatabase { + execAsync(sql: string): Promise; + runAsync(sql: string, params?: unknown[]): Promise; + getAllAsync(sql: string, params?: unknown[]): Promise; + getFirstAsync(sql: string, params?: unknown[]): Promise; +} + +/** + * Opens `linkora.db` and applies migrations. + * + * `expo-sqlite` is imported lazily so that environments without the native + * module (web, Jest) can fall back to the in-memory store without crashing at + * import time. Returns `null` when the module is unavailable. + */ +export async function openLinkoraDatabase(): Promise { + let SQLite: typeof import("expo-sqlite") | null = null; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + SQLite = require("expo-sqlite"); + } catch { + return null; + } + if (!SQLite) return null; + + const driver = createDriver(SQLite); + if (!driver) return null; + + for (const migration of MIGRATIONS) { + await driver.execAsync(migration); + } + return driver; +} + +/** + * Adapts whichever `expo-sqlite` API surface is present (the modern + * `openDatabaseAsync` or the legacy `openDatabase` callback API) to + * {@link SqlDatabase}. + */ +function createDriver(SQLite: typeof import("expo-sqlite")): SqlDatabase | null { + const sqlite = SQLite as unknown as Record; + + // Modern async API (expo-sqlite >= SDK 50): already promise based. + if (typeof sqlite.openDatabaseSync === "function") { + const db = (sqlite.openDatabaseSync as (name: string) => SqlDatabase)(DATABASE_NAME); + if (db && typeof db.getAllAsync === "function") { + return db; + } + } + + // Legacy callback API (expo-sqlite SDK 49): wrap `transaction`/`executeSql`. + if (typeof sqlite.openDatabase === "function") { + const legacy = (sqlite.openDatabase as (name: string) => LegacyDatabase)(DATABASE_NAME); + return wrapLegacyDatabase(legacy); + } + + return null; +} + +interface LegacyResultSet { + rows: { _array?: unknown[]; length: number; item: (i: number) => unknown }; +} +interface LegacyTransaction { + executeSql: ( + sql: string, + params: unknown[], + success: (tx: LegacyTransaction, result: LegacyResultSet) => void, + error: (tx: LegacyTransaction, err: unknown) => boolean + ) => void; +} +interface LegacyDatabase { + transaction: ( + cb: (tx: LegacyTransaction) => void, + error?: (err: unknown) => void, + success?: () => void + ) => void; +} + +function wrapLegacyDatabase(db: LegacyDatabase): SqlDatabase { + const run = (sql: string, params: unknown[]): Promise => + new Promise((resolve, reject) => { + db.transaction( + (tx) => { + tx.executeSql( + sql, + params, + (_tx, result) => { + const rows = result.rows._array ?? []; + resolve(rows as T[]); + }, + (_tx, err) => { + reject(err); + return true; + } + ); + }, + (err) => reject(err) + ); + }); + + return { + async execAsync(sql) { + // The legacy API does not run multi-statement strings, but every + // migration here is a single statement so this is safe. + await run(sql, []); + }, + async runAsync(sql, params = []) { + await run(sql, params); + }, + async getAllAsync(sql: string, params: unknown[] = []) { + return run(sql, params); + }, + async getFirstAsync(sql: string, params: unknown[] = []) { + const rows = await run(sql, params); + return rows.length > 0 ? rows[0] : null; + }, + }; +} diff --git a/apps/mobile/src/db/index.ts b/apps/mobile/src/db/index.ts new file mode 100644 index 00000000..578b20d3 --- /dev/null +++ b/apps/mobile/src/db/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; +export * from "./schema"; +export { contentHash } from "./contentHash"; +export { MemoryPostStore } from "./MemoryPostStore"; +export { SqlitePostStore } from "./SqlitePostStore"; +export { openLinkoraDatabase, type SqlDatabase } from "./database"; +export { PostRepository, CONFLICT_WINDOW_SECONDS } from "./PostRepository"; diff --git a/apps/mobile/src/db/schema.ts b/apps/mobile/src/db/schema.ts new file mode 100644 index 00000000..ff57e936 --- /dev/null +++ b/apps/mobile/src/db/schema.ts @@ -0,0 +1,50 @@ +/** + * SQLite schema for the local Linkora cache (`linkora.db`). + * + * The DDL lives here so it can be shared by the SQLite store and referenced by + * tests/documentation without importing any native module. + */ + +export const DATABASE_NAME = "linkora.db"; + +export const CREATE_POSTS_TABLE = ` + CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + author TEXT NOT NULL, + content TEXT NOT NULL, + tip_total TEXT NOT NULL DEFAULT '0', + like_count INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL, + synced_at INTEGER NOT NULL + ); +`; + +export const CREATE_PENDING_POSTS_TABLE = ` + CREATE TABLE IF NOT EXISTS pending_posts ( + local_id TEXT PRIMARY KEY, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT + ); +`; + +export const CREATE_FEED_CURSOR_TABLE = ` + CREATE TABLE IF NOT EXISTS feed_cursor ( + feed_type TEXT PRIMARY KEY, + last_cursor TEXT, + updated_at INTEGER NOT NULL + ); +`; + +/** Index that keeps `getPage` ordered-by-timestamp scans cheap. */ +export const CREATE_POSTS_TIMESTAMP_INDEX = ` + CREATE INDEX IF NOT EXISTS idx_posts_timestamp ON posts (timestamp DESC); +`; + +export const MIGRATIONS: string[] = [ + CREATE_POSTS_TABLE, + CREATE_PENDING_POSTS_TABLE, + CREATE_FEED_CURSOR_TABLE, + CREATE_POSTS_TIMESTAMP_INDEX, +]; diff --git a/apps/mobile/src/db/types.ts b/apps/mobile/src/db/types.ts new file mode 100644 index 00000000..e64667c6 --- /dev/null +++ b/apps/mobile/src/db/types.ts @@ -0,0 +1,73 @@ +/** + * Shared types for the offline-first post cache. + * + * These mirror the SQLite schema defined in {@link ./schema.ts} but are kept + * framework agnostic so the repository can be unit tested without a native + * SQLite module present. + */ + +/** A confirmed post as stored in the `posts` table. */ +export interface PostRecord { + /** Contract-assigned post id. */ + id: string; + author: string; + content: string; + /** Total tips, kept as a string to avoid 53-bit precision loss. */ + tip_total: string; + like_count: number; + /** Unix seconds. */ + timestamp: number; + /** Unix seconds at which the row was last written from the indexer. */ + synced_at: number; +} + +export type PendingStatus = "pending" | "syncing" | "failed"; + +/** A locally created post awaiting on-chain confirmation. */ +export interface PendingPost { + local_id: string; + content: string; + /** Unix seconds. */ + created_at: number; + status: PendingStatus; + error: string | null; +} + +/** Cursor bookkeeping for incremental, cursor-based feed syncs. */ +export interface FeedCursor { + feed_type: string; + last_cursor: string | null; + updated_at: number; +} + +/** + * Storage backend contract. Implemented by both the production SQLite store + * ({@link ./SqlitePostStore.SqlitePostStore}) and the in-memory store + * ({@link ./MemoryPostStore.MemoryPostStore}) used as a fallback and in tests. + */ +export interface PostStore { + init(): Promise; + + upsertPosts(posts: PostRecord[]): Promise; + getPage(offset: number, limit: number): Promise; + /** + * Finds a confirmed post by the same author whose content matches and whose + * timestamp falls within `windowSeconds` of `timestamp`. Used for idempotency + * checks when reconciling stale pending posts. + */ + findMatchingPost( + author: string, + content: string, + timestamp: number, + windowSeconds: number + ): Promise; + + insertPending(post: PendingPost): Promise; + getPending(): Promise; + getPendingById(localId: string): Promise; + updatePendingStatus(localId: string, status: PendingStatus, error: string | null): Promise; + removePending(localId: string): Promise; + + getCursor(feedType: string): Promise; + setCursor(feedType: string, cursor: string | null): Promise; +} diff --git a/apps/mobile/src/hooks/useOfflineFeed.ts b/apps/mobile/src/hooks/useOfflineFeed.ts new file mode 100644 index 00000000..77eacc9b --- /dev/null +++ b/apps/mobile/src/hooks/useOfflineFeed.ts @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { PostRepository } from "../db/PostRepository"; +import type { PendingPost, PostRecord } from "../db/types"; +import { FeedSyncService } from "../sync/feedSync"; +import { + createOptimisticPost, + reconcilePendingPosts, + retryPendingPost, +} from "../sync/optimisticPost"; +import type { IndexerClient, SubmitPost } from "../sync/types"; + +const PAGE_SIZE = 20; + +/** A feed row: either a confirmed post or a locally-pending one. */ +export type OfflineFeedItem = + | { kind: "confirmed"; post: PostRecord } + | { kind: "pending"; post: PendingPost }; + +export interface UseOfflineFeedReturn { + items: OfflineFeedItem[]; + loading: boolean; + syncing: boolean; + error: string | null; + refresh: () => Promise; + loadMore: () => Promise; + hasMore: boolean; + createPost: (content: string) => Promise; + retryPost: (localId: string) => Promise; +} + +export interface UseOfflineFeedParams { + repo: PostRepository; + sync: FeedSyncService; + indexer?: IndexerClient; + submit: SubmitPost; + author: string; +} + +/** + * Drives the Feed screen from the local cache first, then keeps it fresh. + * + * On mount it renders confirmed + pending posts straight from SQLite (instant + * cold start), reconciles any stale pending posts against the indexer, then + * runs a background sync. New posts are created optimistically. + */ +export function useOfflineFeed({ + repo, + sync, + submit, + author, +}: UseOfflineFeedParams): UseOfflineFeedReturn { + const [confirmed, setConfirmed] = useState([]); + const [pending, setPending] = useState([]); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const offsetRef = useRef(0); + + const reloadFromCache = useCallback(async () => { + const [page, pendingPosts] = await Promise.all([repo.getPage(0, PAGE_SIZE), repo.getPending()]); + offsetRef.current = page.length; + setConfirmed(page); + setPending(pendingPosts); + setHasMore(page.length >= PAGE_SIZE); + }, [repo]); + + // Subscribe to sync lifecycle events. + useEffect(() => { + const offStart = sync.events.on("syncStarted", () => setSyncing(true)); + const offDone = sync.events.on("syncCompleted", () => { + setSyncing(false); + void reloadFromCache(); + }); + const offFail = sync.events.on("syncFailed", (p) => { + setSyncing(false); + setError(p.error); + }); + return () => { + offStart(); + offDone(); + offFail(); + }; + }, [sync, reloadFromCache]); + + // Cold start: cache first, then reconcile + background sync. + useEffect(() => { + let active = true; + (async () => { + await reloadFromCache(); + if (!active) return; + setLoading(false); + await reconcilePendingPosts(repo, author); + if (!active) return; + await reloadFromCache(); + void sync.syncNow(); + })(); + return () => { + active = false; + }; + }, [repo, sync, author, reloadFromCache]); + + const refresh = useCallback(async () => { + setError(null); + await sync.syncNow(); + await reloadFromCache(); + }, [sync, reloadFromCache]); + + const loadMore = useCallback(async () => { + if (!hasMore) return; + const next = await repo.getPage(offsetRef.current, PAGE_SIZE); + offsetRef.current += next.length; + setConfirmed((prev) => [...prev, ...next]); + setHasMore(next.length >= PAGE_SIZE); + }, [repo, hasMore]); + + const createPost = useCallback( + async (content: string) => { + const { settled } = await createOptimisticPost(repo, submit, author, content); + setPending(await repo.getPending()); + void settled.then(async () => { + setPending(await repo.getPending()); + await reloadFromCache(); + }); + }, + [repo, submit, author, reloadFromCache] + ); + + const retryPost = useCallback( + async (localId: string) => { + await retryPendingPost(repo, submit, author, localId); + setPending(await repo.getPending()); + await reloadFromCache(); + }, + [repo, submit, author, reloadFromCache] + ); + + const items = useMemo( + () => [ + ...pending.map((post) => ({ kind: "pending" as const, post })), + ...confirmed.map((post) => ({ kind: "confirmed" as const, post })), + ], + [pending, confirmed] + ); + + return { + items, + loading, + syncing, + error, + refresh, + loadMore, + hasMore, + createPost, + retryPost, + }; +} diff --git a/apps/mobile/src/index.ts b/apps/mobile/src/index.ts new file mode 100644 index 00000000..81ed9299 --- /dev/null +++ b/apps/mobile/src/index.ts @@ -0,0 +1,11 @@ +/** + * Offline-first feed: SQLite cache, background sync, optimistic post creation + * and conflict resolution. See `apps/mobile/src/db` and `apps/mobile/src/sync`. + */ +export * from "./db"; +export * from "./sync"; +export { + useOfflineFeed, + type OfflineFeedItem, + type UseOfflineFeedReturn, +} from "./hooks/useOfflineFeed"; diff --git a/apps/mobile/src/sync/__tests__/feedSync.test.ts b/apps/mobile/src/sync/__tests__/feedSync.test.ts new file mode 100644 index 00000000..d0f18063 --- /dev/null +++ b/apps/mobile/src/sync/__tests__/feedSync.test.ts @@ -0,0 +1,117 @@ +import { MemoryPostStore } from "../../db/MemoryPostStore"; +import { PostRepository } from "../../db/PostRepository"; +import type { PostRecord } from "../../db/types"; +import { FeedSyncService } from "../feedSync"; +import type { FetchResult, IndexerClient } from "../types"; + +const AUTHOR = "GABCD1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function makePost(id: string, timestamp: number): PostRecord { + return { + id, + author: AUTHOR, + content: `post ${id}`, + tip_total: "0", + like_count: 0, + timestamp, + synced_at: timestamp, + }; +} + +describe("FeedSyncService", () => { + it("renders feed from cache when the network is unavailable", async () => { + const repo = new PostRepository(new MemoryPostStore()); + await repo.upsert([makePost("a", 100), makePost("b", 200)]); + + const offlineIndexer: IndexerClient = { + fetchPostsSince: jest.fn(async () => { + throw new Error("offline"); + }), + }; + const sync = new FeedSyncService(repo, offlineIndexer); + + const failed = jest.fn(); + sync.events.on("syncFailed", failed); + const ok = await sync.syncNow(); + + expect(ok).toBe(false); + expect(failed).toHaveBeenCalledWith({ error: "offline" }); + // Cached posts are still readable despite the failed sync. + expect((await repo.getPage(0, 10)).map((p) => p.id)).toEqual(["b", "a"]); + }); + + it("fetches only new posts using the persisted cursor", async () => { + const repo = new PostRepository(new MemoryPostStore()); + const calls: Array = []; + const indexer: IndexerClient = { + fetchPostsSince: jest.fn(async (cursor): Promise => { + calls.push(cursor); + if (cursor === null) { + return { posts: [makePost("a", 100), makePost("b", 200)], nextCursor: "200" }; + } + return { posts: [makePost("c", 300)], nextCursor: "300" }; + }), + }; + const sync = new FeedSyncService(repo, indexer); + + await sync.syncNow(); + await sync.syncNow(); + + // First call starts from null, second resumes from the saved cursor. + expect(calls).toEqual([null, "200"]); + expect(await repo.getCursor("home")).toBe("300"); + expect((await repo.getPage(0, 10)).map((p) => p.id)).toEqual(["c", "b", "a"]); + }); + + it("does not reset the cursor when the indexer returns nothing new", async () => { + const repo = new PostRepository(new MemoryPostStore()); + await repo.setCursor("home", "200"); + const indexer: IndexerClient = { + fetchPostsSince: jest.fn(async () => ({ posts: [], nextCursor: null })), + }; + const sync = new FeedSyncService(repo, indexer); + + await sync.syncNow(); + expect(await repo.getCursor("home")).toBe("200"); + }); + + it("emits started and completed events with the fetched count", async () => { + const repo = new PostRepository(new MemoryPostStore()); + const indexer: IndexerClient = { + fetchPostsSince: jest.fn(async () => ({ posts: [makePost("a", 100)], nextCursor: "100" })), + }; + const sync = new FeedSyncService(repo, indexer); + + const started = jest.fn(); + const completed = jest.fn(); + sync.events.on("syncStarted", started); + sync.events.on("syncCompleted", completed); + + await sync.syncNow(); + + expect(started).toHaveBeenCalledTimes(1); + expect(completed).toHaveBeenCalledWith({ fetched: 1, cursor: "100" }); + }); + + it("coalesces concurrent syncs", async () => { + const repo = new PostRepository(new MemoryPostStore()); + let resolveFetch: (r: FetchResult) => void = () => {}; + const indexer: IndexerClient = { + fetchPostsSince: jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ), + }; + const sync = new FeedSyncService(repo, indexer); + + const first = sync.syncNow(); + const second = await sync.syncNow(); // rejected while first is in flight + expect(second).toBe(false); + + resolveFetch({ posts: [], nextCursor: null }); + expect(await first).toBe(true); + expect(indexer.fetchPostsSince).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/sync/__tests__/optimisticPost.test.ts b/apps/mobile/src/sync/__tests__/optimisticPost.test.ts new file mode 100644 index 00000000..4f857ead --- /dev/null +++ b/apps/mobile/src/sync/__tests__/optimisticPost.test.ts @@ -0,0 +1,101 @@ +import { MemoryPostStore } from "../../db/MemoryPostStore"; +import { PostRepository } from "../../db/PostRepository"; +import type { PostRecord } from "../../db/types"; +import { createOptimisticPost, reconcilePendingPosts, retryPendingPost } from "../optimisticPost"; + +const AUTHOR = "GABCD1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +function newRepo(): PostRepository { + return new PostRepository(new MemoryPostStore()); +} + +describe("createOptimisticPost", () => { + it("shows the post immediately as pending, then confirms it after submit", async () => { + const repo = newRepo(); + const submit = jest.fn(async () => "chain-1"); + + const { localId, settled } = await createOptimisticPost(repo, submit, AUTHOR, "gm"); + + // Immediately visible as pending. + const pendingNow = await repo.getPending(); + expect(pendingNow).toHaveLength(1); + expect(pendingNow[0].local_id).toBe(localId); + + await settled; + + // Promoted to a confirmed post, removed from pending. + expect(await repo.getPending()).toHaveLength(0); + const page = await repo.getPage(0, 10); + expect(page).toHaveLength(1); + expect(page[0].id).toBe("chain-1"); + expect(page[0].content).toBe("gm"); + expect(submit).toHaveBeenCalledWith("gm"); + }); + + it("marks the post failed when submit rejects", async () => { + const repo = newRepo(); + const submit = jest.fn(async () => { + throw new Error("tx rejected"); + }); + + const { settled } = await createOptimisticPost(repo, submit, AUTHOR, "gm"); + await settled; + + const [pending] = await repo.getPending(); + expect(pending.status).toBe("failed"); + expect(pending.error).toBe("tx rejected"); + expect(await repo.getPage(0, 10)).toHaveLength(0); + }); +}); + +describe("retryPendingPost", () => { + it("re-submits a failed post and confirms it on success", async () => { + const repo = newRepo(); + const submit = jest + .fn, [string]>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce("chain-9"); + + const { localId, settled } = await createOptimisticPost(repo, submit, AUTHOR, "retry me"); + await settled; + expect((await repo.getPending())[0].status).toBe("failed"); + + await retryPendingPost(repo, submit, AUTHOR, localId); + + expect(await repo.getPending()).toHaveLength(0); + expect((await repo.getPage(0, 10))[0].id).toBe("chain-9"); + expect(submit).toHaveBeenCalledTimes(2); + }); +}); + +describe("reconcilePendingPosts", () => { + it("reconciles a pending post that the indexer already shows, without resubmitting", async () => { + const repo = newRepo(); + const pending = await repo.addPending("local-1", "duplicate"); + const confirmed: PostRecord = { + id: "chain-1", + author: AUTHOR, + content: "duplicate", + tip_total: "0", + like_count: 0, + timestamp: pending.created_at, + synced_at: pending.created_at, + }; + await repo.upsert([confirmed]); + + const reconciled = await reconcilePendingPosts(repo, AUTHOR); + + expect(reconciled).toEqual(["local-1"]); + expect(await repo.getPending()).toHaveLength(0); + }); + + it("leaves unmatched pending posts untouched", async () => { + const repo = newRepo(); + await repo.addPending("local-1", "never confirmed"); + + const reconciled = await reconcilePendingPosts(repo, AUTHOR); + + expect(reconciled).toEqual([]); + expect(await repo.getPending()).toHaveLength(1); + }); +}); diff --git a/apps/mobile/src/sync/emitter.ts b/apps/mobile/src/sync/emitter.ts new file mode 100644 index 00000000..2f2a5fe6 --- /dev/null +++ b/apps/mobile/src/sync/emitter.ts @@ -0,0 +1,18 @@ +/** Minimal typed event emitter with no external dependencies. */ +export class TypedEmitter> { + private listeners: { [K in keyof Events]?: Set<(payload: Events[K]) => void> } = {}; + + on(event: K, listener: (payload: Events[K]) => void): () => void { + const set = (this.listeners[event] ??= new Set()); + set.add(listener); + return () => set.delete(listener); + } + + emit(event: K, payload: Events[K]): void { + this.listeners[event]?.forEach((listener) => listener(payload)); + } + + removeAll(): void { + this.listeners = {}; + } +} diff --git a/apps/mobile/src/sync/feedSync.ts b/apps/mobile/src/sync/feedSync.ts new file mode 100644 index 00000000..bf714e8c --- /dev/null +++ b/apps/mobile/src/sync/feedSync.ts @@ -0,0 +1,110 @@ +import type { PostRepository } from "../db/PostRepository"; +import { TypedEmitter } from "./emitter"; +import type { FeedSyncEvents, IndexerClient } from "./types"; + +export const DEFAULT_FEED_TYPE = "home"; +export const SYNC_PAGE_LIMIT = 50; +/** expo-background-fetch minimum interval, in seconds (15 minutes). */ +export const BACKGROUND_SYNC_INTERVAL_SECONDS = 15 * 60; +export const BACKGROUND_SYNC_TASK = "linkora-feed-sync"; + +export interface FeedSyncOptions { + feedType?: string; + limit?: number; +} + +/** + * Coordinates incremental, cursor-based syncing of the feed from the indexer + * into the local cache. + * + * - `syncNow()` fetches posts since the persisted cursor, upserts them and + * advances the cursor. Designed to be called on app foreground. + * - Emits `syncStarted` / `syncCompleted` / `syncFailed` for the Feed screen. + * - `registerBackgroundSync()` wires it to `expo-background-fetch` for periodic + * background syncs every 15 minutes. + */ +export class FeedSyncService { + readonly events = new TypedEmitter(); + private readonly feedType: string; + private readonly limit: number; + private running = false; + + constructor( + private readonly repo: PostRepository, + private readonly indexer: IndexerClient, + options: FeedSyncOptions = {} + ) { + this.feedType = options.feedType ?? DEFAULT_FEED_TYPE; + this.limit = options.limit ?? SYNC_PAGE_LIMIT; + } + + /** + * Performs one incremental sync. Concurrent calls are coalesced — if a sync + * is already in flight this resolves to `false` without starting another. + */ + async syncNow(): Promise { + if (this.running) return false; + this.running = true; + this.events.emit("syncStarted", undefined); + try { + const cursor = await this.repo.getCursor(this.feedType); + const { posts, nextCursor } = await this.indexer.fetchPostsSince(cursor, this.limit); + if (posts.length > 0) { + await this.repo.upsert(posts); + } + // Only advance the cursor when the indexer returns one, so an empty + // response never resets incremental progress to a full re-fetch. + if (nextCursor !== null) { + await this.repo.setCursor(this.feedType, nextCursor); + } + this.events.emit("syncCompleted", { + fetched: posts.length, + cursor: nextCursor ?? cursor, + }); + return true; + } catch (err) { + this.events.emit("syncFailed", { + error: err instanceof Error ? err.message : String(err), + }); + return false; + } finally { + this.running = false; + } + } + + /** + * Registers the periodic background sync via `expo-background-fetch`. + * + * The native modules are imported lazily and guarded so that environments + * without them (web, tests) are a no-op returning `false`. + */ + async registerBackgroundSync(): Promise { + let BackgroundFetch: typeof import("expo-background-fetch") | null = null; + let TaskManager: typeof import("expo-task-manager") | null = null; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + BackgroundFetch = require("expo-background-fetch"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + TaskManager = require("expo-task-manager"); + } catch { + return false; + } + if (!BackgroundFetch || !TaskManager) return false; + + if (!TaskManager.isTaskDefined(BACKGROUND_SYNC_TASK)) { + TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => { + const ok = await this.syncNow(); + return ok + ? BackgroundFetch!.BackgroundFetchResult.NewData + : BackgroundFetch!.BackgroundFetchResult.Failed; + }); + } + + await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, { + minimumInterval: BACKGROUND_SYNC_INTERVAL_SECONDS, + stopOnTerminate: false, + startOnBoot: true, + }); + return true; + } +} diff --git a/apps/mobile/src/sync/index.ts b/apps/mobile/src/sync/index.ts new file mode 100644 index 00000000..ac8e91e2 --- /dev/null +++ b/apps/mobile/src/sync/index.ts @@ -0,0 +1,16 @@ +export { TypedEmitter } from "./emitter"; +export * from "./types"; +export { + FeedSyncService, + DEFAULT_FEED_TYPE, + SYNC_PAGE_LIMIT, + BACKGROUND_SYNC_INTERVAL_SECONDS, + BACKGROUND_SYNC_TASK, + type FeedSyncOptions, +} from "./feedSync"; +export { + createOptimisticPost, + retryPendingPost, + reconcilePendingPosts, + generateLocalId, +} from "./optimisticPost"; diff --git a/apps/mobile/src/sync/optimisticPost.ts b/apps/mobile/src/sync/optimisticPost.ts new file mode 100644 index 00000000..25f51248 --- /dev/null +++ b/apps/mobile/src/sync/optimisticPost.ts @@ -0,0 +1,87 @@ +import type { PostRepository } from "../db/PostRepository"; +import type { PendingPost } from "../db/types"; +import type { SubmitPost } from "./types"; + +let counter = 0; + +/** Generates a collision-resistant local id for an optimistic post. */ +export function generateLocalId(): string { + counter += 1; + return `local-${Date.now().toString(36)}-${counter.toString(36)}`; +} + +/** + * Creates a post optimistically: it is persisted as `pending` immediately + * (so the feed can show a "sending…" card) and the on-chain transaction is + * submitted in the background. On confirmation the post is promoted to the + * `posts` table; on failure it is marked `failed` for the retry UI. + * + * Returns the local id and a promise that resolves once the background submit + * settles (useful for tests; callers may ignore it). + */ +export async function createOptimisticPost( + repo: PostRepository, + submit: SubmitPost, + author: string, + content: string +): Promise<{ localId: string; settled: Promise }> { + const localId = generateLocalId(); + await repo.addPending(localId, content); + const settled = submitPending(repo, submit, author, localId, content); + return { localId, settled }; +} + +/** Re-submits a previously failed pending post. */ +export async function retryPendingPost( + repo: PostRepository, + submit: SubmitPost, + author: string, + localId: string +): Promise { + const pending = await repo.getPendingById(localId); + if (!pending) return; + await submitPending(repo, submit, author, localId, pending.content); +} + +async function submitPending( + repo: PostRepository, + submit: SubmitPost, + author: string, + localId: string, + content: string +): Promise { + await repo.markSyncing(localId); + try { + const contractId = await submit(content); + await repo.markSynced(localId, contractId, author); + } catch (err) { + await repo.markFailed(localId, err instanceof Error ? err.message : String(err)); + } +} + +/** + * Conflict resolution for posts that were pending while the user was offline. + * + * For each pending post, checks whether the indexer already shows it + * (idempotency by author + content within the 24h conflict window). If so the + * post is reconciled without re-submitting; otherwise it is left untouched for + * the normal submit/retry flow. + * + * Returns the local ids that were reconciled against an existing post. + */ +export async function reconcilePendingPosts( + repo: PostRepository, + author: string, + pending?: PendingPost[] +): Promise { + const list = pending ?? (await repo.getPending()); + const reconciled: string[] = []; + for (const post of list) { + const match = await repo.findConfirmedMatch(author, post); + if (match) { + await repo.markSynced(post.local_id, match.id, author); + reconciled.push(post.local_id); + } + } + return reconciled; +} diff --git a/apps/mobile/src/sync/types.ts b/apps/mobile/src/sync/types.ts new file mode 100644 index 00000000..bacdca17 --- /dev/null +++ b/apps/mobile/src/sync/types.ts @@ -0,0 +1,40 @@ +import type { PostRecord } from "../db/types"; + +/** + * Source of fresh posts. Backed by the indexer in production; injected so the + * sync service can be tested without network access. + */ +export interface IndexerClient { + /** + * Returns posts newer than `cursor` (or the first page when `cursor` is + * `null`), oldest-to-newest, along with the new cursor to persist. + */ + fetchPostsSince(cursor: string | null, limit: number): Promise; +} + +export interface FetchResult { + posts: PostRecord[]; + /** The cursor to persist for the next incremental fetch. */ + nextCursor: string | null; +} + +/** + * Submits a post transaction on chain (e.g. via the SDK `TransactionQueue`). + * Resolves with the contract-assigned post id on confirmation. + */ +export type SubmitPost = (content: string) => Promise; + +export interface SyncCompletedPayload { + fetched: number; + cursor: string | null; +} + +export interface SyncFailedPayload { + error: string; +} + +export type FeedSyncEvents = { + syncStarted: void; + syncCompleted: SyncCompletedPayload; + syncFailed: SyncFailedPayload; +}; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index efafc1cb..2d63c717 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -25,6 +25,8 @@ "hooks/**/*.ts", "hooks/**/*.tsx", "mini-apps/**/*.ts", + "src/**/*.ts", + "src/**/*.tsx", "types/**/*.d.ts", "utils/**/*.ts" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2f585a5..b8e72693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,7 @@ importers: version: 10.9.2(@types/node@22.19.19)(typescript@5.9.3) turbo: specifier: latest - version: 2.9.18 + version: 2.10.0 apps/mobile: dependencies: @@ -49,6 +49,9 @@ importers: expo: specifier: ^49.0.23 version: 49.0.23(@babel/core@7.29.7) + expo-background-fetch: + specifier: ~11.3.0 + version: 11.3.0(expo@49.0.23(@babel/core@7.29.7)) expo-notifications: specifier: ~0.20.1 version: 0.20.1(expo@49.0.23(@babel/core@7.29.7)) @@ -58,6 +61,12 @@ importers: expo-secure-store: specifier: ^12.0.0 version: 12.8.1(expo@49.0.23(@babel/core@7.29.7)) + expo-sqlite: + specifier: ~11.3.3 + version: 11.3.3(expo@49.0.23(@babel/core@7.29.7)) + expo-task-manager: + specifier: ~11.3.0 + version: 11.3.0(expo@49.0.23(@babel/core@7.29.7)) react: specifier: ^18.2.0 version: 18.3.1 @@ -92,7 +101,7 @@ importers: version: 7.29.7(@babel/core@7.29.7) "@testing-library/react-native": specifier: ^12.4.0 - version: 12.9.0(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(react-native@0.72.17(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1) + version: 12.9.0(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(react-native@0.72.17(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1) "@types/jest": specifier: ^29.5.0 version: 29.5.14 @@ -104,16 +113,16 @@ importers: version: 30.4.1(@babel/core@7.29.7) jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) jest-expo: specifier: ~49.0.0 - version: 49.0.0(@babel/core@7.29.7)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(react@18.3.1) + version: 49.0.0(@babel/core@7.29.7)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(react@18.3.1) react-test-renderer: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) ts-jest: specifier: ^29.1.0 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3) typescript: specifier: ^5.1.6 version: 5.9.3 @@ -221,10 +230,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) + version: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) ts-jest: specifier: ^29.1.2 - version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(typescript@5.9.3) typedoc: specifier: ^0.27.9 version: 0.27.9(typescript@5.9.3) @@ -2100,6 +2109,12 @@ packages: integrity: sha512-TI+l71+5aSKnShYclFa14Kum+hQMZ86b95SH6tQUG3qZEmLTarvWpKwqtTwQKqvlJSJrpFiSFu3eCuZokY6zWA==, } + "@expo/websql@1.0.1": + resolution: + { + integrity: sha512-H9/t1V7XXyKC343FJz/LwaVBfDhs6IqhDtSYWpt8LNSQDVjf5NvVJLc5wp+KCpRidZx8+0+YeHJN45HOXmqjFA==, + } + "@expo/xcpretty@4.4.4": resolution: { @@ -3262,6 +3277,7 @@ packages: { integrity: sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==, } + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. "@stellar/stellar-base@13.1.0": resolution: @@ -3269,6 +3285,7 @@ packages: integrity: sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==, } engines: { node: ">=18.0.0" } + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. "@stellar/stellar-base@15.0.0": resolution: @@ -3276,6 +3293,7 @@ packages: integrity: sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==, } engines: { node: ">=20.0.0" } + deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. "@stellar/stellar-sdk@12.3.0": resolution: @@ -3519,50 +3537,50 @@ packages: integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==, } - "@turbo/darwin-64@2.9.18": + "@turbo/darwin-64@2.10.0": resolution: { - integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==, + integrity: sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg==, } cpu: [x64] os: [darwin] - "@turbo/darwin-arm64@2.9.18": + "@turbo/darwin-arm64@2.10.0": resolution: { - integrity: sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ==, + integrity: sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg==, } cpu: [arm64] os: [darwin] - "@turbo/linux-64@2.9.18": + "@turbo/linux-64@2.10.0": resolution: { - integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==, + integrity: sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ==, } cpu: [x64] os: [linux] - "@turbo/linux-arm64@2.9.18": + "@turbo/linux-arm64@2.10.0": resolution: { - integrity: sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg==, + integrity: sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg==, } cpu: [arm64] os: [linux] - "@turbo/windows-64@2.9.18": + "@turbo/windows-64@2.10.0": resolution: { - integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==, + integrity: sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg==, } cpu: [x64] os: [win32] - "@turbo/windows-arm64@2.9.18": + "@turbo/windows-arm64@2.10.0": resolution: { - integrity: sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA==, + integrity: sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A==, } cpu: [arm64] os: [win32] @@ -4575,6 +4593,12 @@ packages: integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, } + argsarray@0.0.1: + resolution: + { + integrity: sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==, + } + aria-query@5.1.3: resolution: { @@ -6585,6 +6609,14 @@ packages: integrity: sha512-5VMTESxgY9GBsspO/esY25SKEa7RyascVkLe/OcL1WgblNFm7xCCEEUIW8VWS1nHJQGYxpMZPr3bEfjMpdWdyA==, } + expo-background-fetch@11.3.0: + resolution: + { + integrity: sha512-aRBCg+kp/5j6XJBrcww8i7TPZ7RbybTLDwmBNzsqjUUKH9M8rkTJxy+eoo1qbFp3NGGrfTSNXPyXkH59hwR1mA==, + } + peerDependencies: + expo: "*" + expo-constants@14.4.2: resolution: { @@ -6685,6 +6717,22 @@ packages: peerDependencies: expo: "*" + expo-sqlite@11.3.3: + resolution: + { + integrity: sha512-73n+mhwi5mO28oVVrDGYcjy28XeUjNtpXVPtEmc+sr/NQ0hKlkIf2PD3/gKsyNuI8O/twNCZxsAQdM32yHGr8A==, + } + peerDependencies: + expo: "*" + + expo-task-manager@11.3.0: + resolution: + { + integrity: sha512-580XaQGOC2IHLjPV3GBeIwlfxtjuBaitENsUxHvCVgWusatWkq0frq7uzG3yVpG3UHUuQwSOHvkJ6X/3EjdYXw==, + } + peerDependencies: + expo: "*" + expo@49.0.23: resolution: { @@ -7497,6 +7545,12 @@ packages: engines: { node: ">=16.x" } hasBin: true + immediate@3.3.0: + resolution: + { + integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==, + } + import-fresh@2.0.0: resolution: { @@ -9711,6 +9765,12 @@ packages: } engines: { node: ">=0.12.0" } + noop-fn@1.0.0: + resolution: + { + integrity: sha512-pQ8vODlgXt2e7A3mIbFDlizkr46r75V+BJxVAyat8Jl7YmI513gG5cfyRL0FedKraoZ+VAouI1h4/IWpus5pcQ==, + } + normalize-path@3.0.0: resolution: { @@ -10366,6 +10426,12 @@ packages: } engines: { node: ">=0.10.0" } + pouchdb-collections@1.0.1: + resolution: + { + integrity: sha512-31db6JRg4+4D5Yzc2nqsRqsA2oOkZS8DpFav3jf/qVNBxusKa2ClkEIZ2bJNpaDbMfWtnuSq59p6Bn+CipPMdg==, + } + prelude-ls@1.2.1: resolution: { @@ -12012,6 +12078,12 @@ packages: integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==, } + tiny-queue@0.2.1: + resolution: + { + integrity: sha512-EijGsv7kzd9I9g0ByCl6h42BWNGUZrlCSejfrb3AKeHC33SGbASu1VDf5O3rRiiUOhAC9CHdZxFPbZu0HmR70A==, + } + tinyglobby@0.2.16: resolution: { @@ -12167,10 +12239,10 @@ packages: engines: { node: ">=18.0.0" } hasBin: true - turbo@2.9.18: + turbo@2.10.0: resolution: { - integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==, + integrity: sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw==, } hasBin: true @@ -12392,6 +12464,12 @@ packages: } engines: { node: ">=4" } + unimodules-app-loader@4.2.0: + resolution: + { + integrity: sha512-q3FHCw04JUjsDXxvTvJPl63sBseHCiqHWD/3imOseGP+PZLzW46boQmnxSaHcdIOoWK88N/npyEh6eDN9Wyvng==, + } + unique-filename@1.1.1: resolution: { @@ -14436,6 +14514,14 @@ snapshots: "@expo/vector-icons@13.0.0": {} + "@expo/websql@1.0.1": + dependencies: + argsarray: 0.0.1 + immediate: 3.3.0 + noop-fn: 1.0.0 + pouchdb-collections: 1.0.1 + tiny-queue: 0.2.1 + "@expo/xcpretty@4.4.4": dependencies: "@babel/code-frame": 7.29.7 @@ -15564,7 +15650,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - "@testing-library/react-native@12.9.0(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(react-native@0.72.17(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)": + "@testing-library/react-native@12.9.0(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(react-native@0.72.17(@babel/core@7.29.7)(@babel/preset-env@7.29.7(@babel/core@7.29.7))(react@18.3.1))(react-test-renderer@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: jest-matcher-utils: 29.7.0 pretty-format: 29.7.0 @@ -15573,7 +15659,7 @@ snapshots: react-test-renderer: 18.3.1(react@18.3.1) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) "@testing-library/react@14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: @@ -15605,22 +15691,22 @@ snapshots: "@tsconfig/node16@1.0.4": {} - "@turbo/darwin-64@2.9.18": + "@turbo/darwin-64@2.10.0": optional: true - "@turbo/darwin-arm64@2.9.18": + "@turbo/darwin-arm64@2.10.0": optional: true - "@turbo/linux-64@2.9.18": + "@turbo/linux-64@2.10.0": optional: true - "@turbo/linux-arm64@2.9.18": + "@turbo/linux-arm64@2.10.0": optional: true - "@turbo/windows-64@2.9.18": + "@turbo/windows-64@2.10.0": optional: true - "@turbo/windows-arm64@2.9.18": + "@turbo/windows-arm64@2.10.0": optional: true "@tybys/wasm-util@0.10.1": @@ -16433,6 +16519,8 @@ snapshots: argparse@2.0.1: {} + argsarray@0.0.1: {} + aria-query@5.1.3: dependencies: deep-equal: 2.2.3 @@ -17626,7 +17714,7 @@ snapshots: "@typescript-eslint/parser": 7.18.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -17891,6 +17979,11 @@ snapshots: - expo - supports-color + expo-background-fetch@11.3.0(expo@49.0.23(@babel/core@7.29.7)): + dependencies: + expo: 49.0.23(@babel/core@7.29.7) + expo-task-manager: 11.3.0(expo@49.0.23(@babel/core@7.29.7)) + expo-constants@14.4.2(expo@49.0.23(@babel/core@7.29.7)): dependencies: "@expo/config": 8.1.2 @@ -17995,6 +18088,16 @@ snapshots: - expo-modules-autolinking - supports-color + expo-sqlite@11.3.3(expo@49.0.23(@babel/core@7.29.7)): + dependencies: + "@expo/websql": 1.0.1 + expo: 49.0.23(@babel/core@7.29.7) + + expo-task-manager@11.3.0(expo@49.0.23(@babel/core@7.29.7)): + dependencies: + expo: 49.0.23(@babel/core@7.29.7) + unimodules-app-loader: 4.2.0 + expo@49.0.23(@babel/core@7.29.7): dependencies: "@babel/runtime": 7.29.7 @@ -18559,6 +18662,8 @@ snapshots: dependencies: queue: 6.0.2 + immediate@3.3.0: {} + import-fresh@2.0.0: dependencies: caller-path: 2.0.0 @@ -19073,7 +19178,7 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@49.0.0(@babel/core@7.29.7)(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)))(react@18.3.1): + jest-expo@49.0.0(@babel/core@7.29.7)(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)))(react@18.3.1): dependencies: "@expo/config": 8.1.2 "@jest/create-cache-key-function": 29.7.0 @@ -19081,7 +19186,7 @@ snapshots: find-up: 5.0.0 jest-environment-jsdom: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))) json5: 2.2.3 lodash: 4.18.1 react-test-renderer: 18.2.0(react@18.3.1) @@ -19313,11 +19418,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3))): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3))): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@22.19.19)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.9.3)) + jest: 29.7.0(@types/node@20.19.39)(ts-node@10.9.2(@types/node@20.19.39)(typescript@5.9.3)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -20459,6 +20564,8 @@ snapshots: node-stream-zip@1.15.0: {} + noop-fn@1.0.0: {} + normalize-path@3.0.0: {} npm-package-arg@7.0.0: @@ -20843,6 +20950,8 @@ snapshots: dependencies: xtend: 4.0.2 + pouchdb-collections@1.0.1: {} + prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -21930,6 +22039,8 @@ snapshots: through@2.3.8: {} + tiny-queue@0.2.1: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -22070,14 +22181,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.9.18: + turbo@2.10.0: optionalDependencies: - "@turbo/darwin-64": 2.9.18 - "@turbo/darwin-arm64": 2.9.18 - "@turbo/linux-64": 2.9.18 - "@turbo/linux-arm64": 2.9.18 - "@turbo/windows-64": 2.9.18 - "@turbo/windows-arm64": 2.9.18 + "@turbo/darwin-64": 2.10.0 + "@turbo/darwin-arm64": 2.10.0 + "@turbo/linux-64": 2.10.0 + "@turbo/linux-arm64": 2.10.0 + "@turbo/windows-64": 2.10.0 + "@turbo/windows-arm64": 2.10.0 tweetnacl@1.0.3: {} @@ -22201,6 +22312,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unimodules-app-loader@4.2.0: {} + unique-filename@1.1.1: dependencies: unique-slug: 2.0.2