Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions apps/mobile/src/db/MemoryPostStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, PostRecord>();
private pending = new Map<string, PendingPost>();
private cursors = new Map<string, FeedCursor>();

async init(): Promise<void> {
// Nothing to migrate for the in-memory backend.
}

async upsertPosts(posts: PostRecord[]): Promise<void> {
for (const post of posts) {
this.posts.set(post.id, { ...post });
}
}

async getPage(offset: number, limit: number): Promise<PostRecord[]> {
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<PostRecord | null> {
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<void> {
this.pending.set(post.local_id, { ...post });
}

async getPending(): Promise<PendingPost[]> {
return [...this.pending.values()]
.sort((a, b) => a.created_at - b.created_at)
.map((post) => ({ ...post }));
}

async getPendingById(localId: string): Promise<PendingPost | null> {
const found = this.pending.get(localId);
return found ? { ...found } : null;
}

async updatePendingStatus(
localId: string,
status: PendingStatus,
error: string | null
): Promise<void> {
const existing = this.pending.get(localId);
if (!existing) return;
this.pending.set(localId, { ...existing, status, error });
}

async removePending(localId: string): Promise<void> {
this.pending.delete(localId);
}

async getCursor(feedType: string): Promise<string | null> {
return this.cursors.get(feedType)?.last_cursor ?? null;
}

async setCursor(feedType: string, cursor: string | null): Promise<void> {
this.cursors.set(feedType, {
feed_type: feedType,
last_cursor: cursor,
updated_at: Math.floor(Date.now() / 1000),
});
}
}
128 changes: 128 additions & 0 deletions apps/mobile/src/db/PostRepository.ts
Original file line number Diff line number Diff line change
@@ -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<PostRepository> {
const db = await openLinkoraDatabase();
const store: PostStore = db ? new SqlitePostStore(db) : new MemoryPostStore();
await store.init();
return new PostRepository(store);
}

init(): Promise<void> {
return this.store.init();
}

/** Upserts confirmed posts coming from the indexer. */
upsert(posts: PostRecord[]): Promise<void> {
return this.store.upsertPosts(posts);
}

/** Reads a page of confirmed posts ordered newest first. */
getPage(offset: number, limit: number): Promise<PostRecord[]> {
return this.store.getPage(offset, limit);
}

/** Lists locally created posts awaiting confirmation. */
getPending(): Promise<PendingPost[]> {
return this.store.getPending();
}

getPendingById(localId: string): Promise<PendingPost | null> {
return this.store.getPendingById(localId);
}

/** Inserts a new optimistic (pending) post and returns it. */
async addPending(localId: string, content: string): Promise<PendingPost> {
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<void> {
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<PostRecord | null> {
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<void> {
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<PostRecord | null> {
return this.store.findMatchingPost(
author,
pending.content,
pending.created_at,
CONFLICT_WINDOW_SECONDS
);
}

getCursor(feedType: string): Promise<string | null> {
return this.store.getCursor(feedType);
}

setCursor(feedType: string, cursor: string | null): Promise<void> {
return this.store.setCursor(feedType, cursor);
}
}
133 changes: 133 additions & 0 deletions apps/mobile/src/db/SqlitePostStore.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// Migrations are applied by `openLinkoraDatabase`; nothing else to do.
}

async upsertPosts(posts: PostRecord[]): Promise<void> {
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<PostRecord[]> {
return this.db.getAllAsync<PostRecord>(
`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<PostRecord | null> {
return this.db.getFirstAsync<PostRecord>(
`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<void> {
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<PendingPost[]> {
return this.db.getAllAsync<PendingPost>(
`SELECT local_id, content, created_at, status, error
FROM pending_posts
ORDER BY created_at ASC;`
);
}

async getPendingById(localId: string): Promise<PendingPost | null> {
return this.db.getFirstAsync<PendingPost>(
`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<void> {
await this.db.runAsync(`UPDATE pending_posts SET status = ?, error = ? WHERE local_id = ?;`, [
status,
error,
localId,
]);
}

async removePending(localId: string): Promise<void> {
await this.db.runAsync(`DELETE FROM pending_posts WHERE local_id = ?;`, [localId]);
}

async getCursor(feedType: string): Promise<string | null> {
const row = await this.db.getFirstAsync<FeedCursor>(
`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<void> {
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)]
);
}
}
Loading