diff --git a/apps/native/app/__tests__/_layout.test.tsx b/apps/native/app/__tests__/_layout.test.tsx
index d634115..4a74ebc 100644
--- a/apps/native/app/__tests__/_layout.test.tsx
+++ b/apps/native/app/__tests__/_layout.test.tsx
@@ -81,6 +81,9 @@ jest.mock("@/utils/trpc", () => ({
jest.mock("@/lib/local-db-provider", () => ({
LocalDbProvider: ({ children }: { children: React.ReactNode }) => children,
}));
+jest.mock("@/lib/sync-provider", () => ({
+ SyncProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
import Layout from "../_layout";
diff --git a/apps/native/app/_layout.tsx b/apps/native/app/_layout.tsx
index e5f08b1..34cb31f 100644
--- a/apps/native/app/_layout.tsx
+++ b/apps/native/app/_layout.tsx
@@ -19,6 +19,7 @@ import { AppThemeProvider } from "@/contexts/app-theme-context";
import { WorkoutProvider } from "@/contexts/workout-context";
import { authClient } from "@/lib/auth-client";
import { LocalDbProvider } from "@/lib/local-db-provider";
+import { SyncProvider } from "@/lib/sync-provider";
import { queryClient } from "@/utils/trpc";
SplashScreen.preventAutoHideAsync();
@@ -80,19 +81,21 @@ export default function Layout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/apps/native/lib/__tests__/sync-auth-retry.test.ts b/apps/native/lib/__tests__/sync-auth-retry.test.ts
new file mode 100644
index 0000000..af28f6c
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-auth-retry.test.ts
@@ -0,0 +1,67 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+import { runSyncWithRetry } from "../sync-engine";
+import type { SyncClient } from "../sync-engine";
+
+function seedQueue(db: any) {
+ db.insert(schema.syncQueue)
+ .values({
+ id: "sq-1",
+ tableName: "workouts",
+ recordId: "w-1",
+ operation: "insert",
+ payload: JSON.stringify({ id: "w-1", userId: "u-1", title: "Push Day" }),
+ createdAt: Date.now(),
+ })
+ .run();
+}
+
+describe("runSyncWithRetry", () => {
+ it("retries once after UNAUTHORIZED, then succeeds", async () => {
+ const { db } = createTestDb();
+ seedQueue(db);
+
+ const mockMutate = jest
+ .fn()
+ .mockRejectedValueOnce({ code: "UNAUTHORIZED" })
+ .mockResolvedValueOnce({ success: true });
+
+ const mockQuery = jest.fn().mockResolvedValue({ changes: [], cursor: 0 });
+
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: mockMutate },
+ pull: { query: mockQuery },
+ },
+ };
+
+ const refreshSession = jest.fn().mockResolvedValue(undefined);
+
+ await runSyncWithRetry(db, client, refreshSession);
+
+ expect(refreshSession).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledTimes(2);
+ });
+
+ it("throws after both attempts fail with UNAUTHORIZED", async () => {
+ const { db } = createTestDb();
+ seedQueue(db);
+
+ const unauthorizedError = { code: "UNAUTHORIZED" };
+ const mockMutate = jest.fn().mockRejectedValue(unauthorizedError);
+ const mockQuery = jest.fn().mockResolvedValue({ changes: [], cursor: 0 });
+
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: mockMutate },
+ pull: { query: mockQuery },
+ },
+ };
+
+ const refreshSession = jest.fn().mockResolvedValue(undefined);
+
+ await expect(runSyncWithRetry(db, client, refreshSession)).rejects.toEqual(unauthorizedError);
+ expect(refreshSession).toHaveBeenCalledTimes(1);
+ expect(mockMutate).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-debounce.test.ts b/apps/native/lib/__tests__/sync-debounce.test.ts
new file mode 100644
index 0000000..9977457
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-debounce.test.ts
@@ -0,0 +1,50 @@
+import { createSyncScheduler } from "../sync-engine";
+
+describe("createSyncScheduler", () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("fires syncFn once after 1s debounce when notifyWrite called twice", () => {
+ const syncFn = jest.fn().mockResolvedValue(undefined);
+ const { notifyWrite, cleanup } = createSyncScheduler(syncFn);
+
+ notifyWrite();
+ notifyWrite();
+
+ expect(syncFn).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1000);
+
+ expect(syncFn).toHaveBeenCalledTimes(1);
+
+ cleanup();
+ });
+
+ it("triggerNow fires immediately", () => {
+ const syncFn = jest.fn().mockResolvedValue(undefined);
+ const { triggerNow, cleanup } = createSyncScheduler(syncFn);
+
+ triggerNow();
+
+ expect(syncFn).toHaveBeenCalledTimes(1);
+
+ cleanup();
+ });
+
+ it("cleanup cancels pending debounce", () => {
+ const syncFn = jest.fn().mockResolvedValue(undefined);
+ const { notifyWrite, cleanup } = createSyncScheduler(syncFn);
+
+ notifyWrite();
+ cleanup();
+
+ jest.advanceTimersByTime(2000);
+
+ expect(syncFn).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-lww.test.ts b/apps/native/lib/__tests__/sync-lww.test.ts
new file mode 100644
index 0000000..5919cdc
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-lww.test.ts
@@ -0,0 +1,102 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+import { pullChanges, setLastSyncCursor } from "../sync-engine";
+import type { SyncClient } from "../sync-engine";
+
+describe("LWW on pull", () => {
+ function setup() {
+ const { db } = createTestDb();
+ db.insert(schema.user)
+ .values({ id: "u-1", name: "Test", email: "t@t.com", updatedAt: new Date() })
+ .run();
+ return db;
+ }
+
+ it("preserves local row when local updatedAt is newer than server", () => {
+ const db = setup();
+
+ // Insert local workout with updatedAt=9000
+ db.insert(schema.workouts)
+ .values({
+ id: "w-1",
+ userId: "u-1",
+ title: "Local Title",
+ createdAt: new Date(1000),
+ updatedAt: new Date(9000),
+ })
+ .run();
+
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: jest.fn() },
+ pull: {
+ query: jest.fn().mockResolvedValue({
+ changes: [
+ {
+ table: "workouts",
+ id: "w-1",
+ userId: "u-1",
+ title: "Server Title",
+ createdAt: 1000,
+ updatedAt: 5000,
+ deletedAt: null,
+ },
+ ],
+ cursor: 5000,
+ }),
+ },
+ },
+ };
+
+ setLastSyncCursor(db, 0);
+
+ return pullChanges(db, client).then(() => {
+ const rows = db.select().from(schema.workouts).all();
+ expect(rows[0].title).toBe("Local Title");
+ });
+ });
+
+ it("applies server row when server updatedAt is newer than local", () => {
+ const db = setup();
+
+ // Insert local workout with updatedAt=3000
+ db.insert(schema.workouts)
+ .values({
+ id: "w-1",
+ userId: "u-1",
+ title: "Local Title",
+ createdAt: new Date(1000),
+ updatedAt: new Date(3000),
+ })
+ .run();
+
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: jest.fn() },
+ pull: {
+ query: jest.fn().mockResolvedValue({
+ changes: [
+ {
+ table: "workouts",
+ id: "w-1",
+ userId: "u-1",
+ title: "Server Title",
+ createdAt: 1000,
+ updatedAt: 9000,
+ deletedAt: null,
+ },
+ ],
+ cursor: 9000,
+ }),
+ },
+ },
+ };
+
+ setLastSyncCursor(db, 0);
+
+ return pullChanges(db, client).then(() => {
+ const rows = db.select().from(schema.workouts).all();
+ expect(rows[0].title).toBe("Server Title");
+ });
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-meta.test.ts b/apps/native/lib/__tests__/sync-meta.test.ts
new file mode 100644
index 0000000..f966136
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-meta.test.ts
@@ -0,0 +1,22 @@
+import { createTestDb } from "../test-db-helper";
+import { getLastSyncCursor, setLastSyncCursor } from "../sync-engine";
+
+describe("_sync_meta cursor", () => {
+ it("returns 0 when no cursor is stored", () => {
+ const { db } = createTestDb();
+ expect(getLastSyncCursor(db)).toBe(0);
+ });
+
+ it("stores and retrieves a cursor", () => {
+ const { db } = createTestDb();
+ setLastSyncCursor(db, 1234567890);
+ expect(getLastSyncCursor(db)).toBe(1234567890);
+ });
+
+ it("overwrites existing cursor on re-set", () => {
+ const { db } = createTestDb();
+ setLastSyncCursor(db, 1000);
+ setLastSyncCursor(db, 2000);
+ expect(getLastSyncCursor(db)).toBe(2000);
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-provider.test.tsx b/apps/native/lib/__tests__/sync-provider.test.tsx
new file mode 100644
index 0000000..1e11b94
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-provider.test.tsx
@@ -0,0 +1,94 @@
+jest.mock("expo-sqlite", () => ({
+ openDatabaseSync: jest.fn(() => "mock-sqlite-db"),
+}));
+
+jest.mock("@ironlog/db/client", () => ({
+ createLocalDb: jest.fn(() => ({
+ select: jest.fn(),
+ insert: jest.fn(),
+ delete: jest.fn(),
+ update: jest.fn(),
+ })),
+}));
+
+jest.mock("drizzle-orm/expo-sqlite/migrator", () => ({
+ useMigrations: jest.fn(() => ({ success: true, error: undefined })),
+}));
+
+jest.mock("@ironlog/db/local-migrations/migrations", () => ({
+ default: { journal: { entries: [] }, migrations: {} },
+}));
+
+jest.mock("expo-network", () => ({
+ getNetworkStateAsync: jest.fn().mockResolvedValue({ isConnected: true }),
+}));
+
+jest.mock("@react-native-community/netinfo", () => ({
+ addEventListener: jest.fn(() => jest.fn()),
+}));
+
+jest.mock("../local-db-provider", () => {
+ const mockDb = {
+ select: jest.fn(() => ({ from: jest.fn(() => ({ all: jest.fn(() => []) })) })),
+ insert: jest.fn(),
+ delete: jest.fn(),
+ update: jest.fn(),
+ };
+ return {
+ useLocalDb: jest.fn(() => mockDb),
+ LocalDbProvider: ({ children }: { children: React.ReactNode }) => children,
+ };
+});
+
+jest.mock("@/utils/trpc", () => ({
+ trpc: {},
+ queryClient: {},
+}));
+
+jest.mock("@/lib/auth-client", () => ({
+ authClient: {
+ getSession: jest.fn().mockResolvedValue({}),
+ },
+}));
+
+import React from "react";
+import { renderHook, waitFor } from "@testing-library/react-native";
+import { SyncProvider, useSyncStatus } from "../sync-provider";
+import * as Network from "expo-network";
+
+describe("SyncProvider", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (Network.getNetworkStateAsync as jest.Mock).mockResolvedValue({ isConnected: true });
+ });
+
+ function wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+ }
+
+ it("provides synced status after successful init", async () => {
+ const { result } = renderHook(() => useSyncStatus(), { wrapper });
+
+ await waitFor(() => {
+ expect(["synced", "syncing"]).toContain(result.current.status);
+ });
+ });
+
+ it("provides offline status when network unavailable", async () => {
+ (Network.getNetworkStateAsync as jest.Mock).mockResolvedValue({ isConnected: false });
+
+ const { result } = renderHook(() => useSyncStatus(), { wrapper });
+
+ await waitFor(() => {
+ expect(result.current.status).toBe("offline");
+ });
+ });
+
+ it("exposes notifyWrite as a function", async () => {
+ const { result } = renderHook(() => useSyncStatus(), { wrapper });
+
+ await waitFor(() => {
+ expect(typeof result.current.notifyWrite).toBe("function");
+ });
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-pull.test.ts b/apps/native/lib/__tests__/sync-pull.test.ts
new file mode 100644
index 0000000..a3cc313
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-pull.test.ts
@@ -0,0 +1,53 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+import { pullChanges, setLastSyncCursor, getLastSyncCursor } from "../sync-engine";
+import type { SyncClient } from "../sync-engine";
+
+describe("pullChanges", () => {
+ it("inserts pulled workout into local table and updates cursor", () => {
+ const { db } = createTestDb();
+
+ // Need a user for FK constraint
+ db.insert(schema.user)
+ .values({ id: "u-1", name: "Test", email: "t@t.com", updatedAt: new Date() })
+ .run();
+
+ const mockQuery = jest.fn().mockResolvedValue({
+ changes: [
+ {
+ table: "workouts",
+ id: "w-1",
+ userId: "u-1",
+ title: "Pull Day",
+ createdAt: 5000,
+ updatedAt: 5000,
+ deletedAt: null,
+ },
+ ],
+ cursor: 5000,
+ });
+
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: jest.fn() },
+ pull: { query: mockQuery },
+ },
+ };
+
+ setLastSyncCursor(db, 0);
+
+ return pullChanges(db, client).then(() => {
+ // Verify query called with cursor
+ expect(mockQuery).toHaveBeenCalledWith({ cursor: 0 });
+
+ // Verify workout inserted locally
+ const workouts = db.select().from(schema.workouts).all();
+ expect(workouts).toHaveLength(1);
+ expect(workouts[0].id).toBe("w-1");
+ expect(workouts[0].title).toBe("Pull Day");
+
+ // Verify cursor updated
+ expect(getLastSyncCursor(db)).toBe(5000);
+ });
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-push.test.ts b/apps/native/lib/__tests__/sync-push.test.ts
new file mode 100644
index 0000000..7fea17f
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-push.test.ts
@@ -0,0 +1,79 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+import { pushChanges } from "../sync-engine";
+import type { SyncClient } from "../sync-engine";
+
+describe("pushChanges", () => {
+ it("sends queued entries to server and clears queue on success", () => {
+ const { db } = createTestDb();
+
+ // Seed 2 queue entries
+ db.insert(schema.syncQueue)
+ .values([
+ {
+ id: "sq-1",
+ tableName: "workouts",
+ recordId: "w-1",
+ operation: "insert",
+ payload: JSON.stringify({ id: "w-1", userId: "u-1", title: "Push Day" }),
+ createdAt: 1000,
+ },
+ {
+ id: "sq-2",
+ tableName: "exercises",
+ recordId: "e-1",
+ operation: "update",
+ payload: JSON.stringify({ id: "e-1", userId: "u-1", name: "Bench Press" }),
+ createdAt: 2000,
+ },
+ ])
+ .run();
+
+ const mockMutate = jest.fn().mockResolvedValue({ success: true });
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: mockMutate },
+ pull: { query: jest.fn() },
+ },
+ };
+
+ return pushChanges(db, client).then(() => {
+ // Verify client called with correct shape
+ expect(mockMutate).toHaveBeenCalledTimes(1);
+ const arg = mockMutate.mock.calls[0][0];
+ expect(arg.changes).toHaveLength(2);
+ expect(arg.changes[0]).toMatchObject({
+ table: "workouts",
+ id: "w-1",
+ data: { id: "w-1", userId: "u-1", title: "Push Day" },
+ updatedAt: expect.any(Number),
+ });
+ expect(arg.changes[1]).toMatchObject({
+ table: "exercises",
+ id: "e-1",
+ data: { id: "e-1", userId: "u-1", name: "Bench Press" },
+ updatedAt: expect.any(Number),
+ });
+
+ // Verify queue cleared
+ const remaining = db.select().from(schema.syncQueue).all();
+ expect(remaining).toHaveLength(0);
+ });
+ });
+
+ it("does nothing when queue is empty", () => {
+ const { db } = createTestDb();
+
+ const mockMutate = jest.fn();
+ const client: SyncClient = {
+ sync: {
+ push: { mutate: mockMutate },
+ pull: { query: jest.fn() },
+ },
+ };
+
+ return pushChanges(db, client).then(() => {
+ expect(mockMutate).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-queue.test.ts b/apps/native/lib/__tests__/sync-queue.test.ts
new file mode 100644
index 0000000..437f7d8
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-queue.test.ts
@@ -0,0 +1,34 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+
+describe("_sync_queue enqueue", () => {
+ it("inserts and reads back a sync queue entry with correct defaults", () => {
+ const { db } = createTestDb();
+
+ const id = "sq-1";
+ const now = Date.now();
+
+ db.insert(schema.syncQueue)
+ .values({
+ id,
+ tableName: "workouts",
+ recordId: "w-1",
+ operation: "insert",
+ payload: JSON.stringify({ title: "Push Day" }),
+ createdAt: now,
+ })
+ .run();
+
+ const rows = db.select().from(schema.syncQueue).all();
+
+ expect(rows).toHaveLength(1);
+ expect(rows[0].id).toBe(id);
+ expect(rows[0].tableName).toBe("workouts");
+ expect(rows[0].recordId).toBe("w-1");
+ expect(rows[0].operation).toBe("insert");
+ expect(rows[0].payload).toBe(JSON.stringify({ title: "Push Day" }));
+ expect(rows[0].createdAt).toBe(now);
+ expect(rows[0].attempts).toBe(0);
+ expect(rows[0].lastError).toBeNull();
+ });
+});
diff --git a/apps/native/lib/__tests__/sync-write.test.ts b/apps/native/lib/__tests__/sync-write.test.ts
new file mode 100644
index 0000000..8fc1f0d
--- /dev/null
+++ b/apps/native/lib/__tests__/sync-write.test.ts
@@ -0,0 +1,89 @@
+import * as schema from "@ironlog/db/schema";
+import { createTestDb } from "../test-db-helper";
+import { syncWrite } from "../sync-engine";
+
+describe("syncWrite", () => {
+ function setup() {
+ const { db } = createTestDb();
+ db.insert(schema.user)
+ .values({ id: "u-1", name: "Test", email: "t@t.com", updatedAt: new Date() })
+ .run();
+ return db;
+ }
+
+ it("insert: writes row to workouts + enqueues in _sync_queue", () => {
+ const db = setup();
+
+ syncWrite(db, "workouts", "insert", {
+ id: "w-1",
+ userId: "u-1",
+ title: "Push Day",
+ });
+
+ const workouts = db.select().from(schema.workouts).all();
+ expect(workouts).toHaveLength(1);
+ expect(workouts[0].id).toBe("w-1");
+ expect(workouts[0].title).toBe("Push Day");
+ expect(workouts[0].updatedAt).toBeInstanceOf(Date);
+
+ const queue = db.select().from(schema.syncQueue).all();
+ expect(queue).toHaveLength(1);
+ expect(queue[0].tableName).toBe("workouts");
+ expect(queue[0].recordId).toBe("w-1");
+ expect(queue[0].operation).toBe("insert");
+
+ const payload = JSON.parse(queue[0].payload);
+ expect(payload.title).toBe("Push Day");
+ });
+
+ it("update: updates existing row + enqueues with operation=update", () => {
+ const db = setup();
+
+ // First insert
+ syncWrite(db, "workouts", "insert", {
+ id: "w-1",
+ userId: "u-1",
+ title: "Push Day",
+ });
+
+ // Then update
+ syncWrite(db, "workouts", "update", {
+ id: "w-1",
+ userId: "u-1",
+ title: "Pull Day",
+ });
+
+ const workouts = db.select().from(schema.workouts).all();
+ expect(workouts).toHaveLength(1);
+ expect(workouts[0].title).toBe("Pull Day");
+
+ const queue = db.select().from(schema.syncQueue).all();
+ expect(queue).toHaveLength(2);
+ expect(queue[1].operation).toBe("update");
+ });
+
+ it("delete: sets deletedAt + enqueues with operation=delete", () => {
+ const db = setup();
+
+ // First insert
+ syncWrite(db, "workouts", "insert", {
+ id: "w-1",
+ userId: "u-1",
+ title: "Push Day",
+ });
+
+ // Then delete
+ syncWrite(db, "workouts", "delete", {
+ id: "w-1",
+ userId: "u-1",
+ });
+
+ const workouts = db.select().from(schema.workouts).all();
+ expect(workouts).toHaveLength(1);
+ expect(workouts[0].deletedAt).toBeInstanceOf(Date);
+
+ const queue = db.select().from(schema.syncQueue).all();
+ expect(queue).toHaveLength(2);
+ expect(queue[1].operation).toBe("delete");
+ });
+});
diff --git a/apps/native/lib/sync-engine.ts b/apps/native/lib/sync-engine.ts
new file mode 100644
index 0000000..60a6dad
--- /dev/null
+++ b/apps/native/lib/sync-engine.ts
@@ -0,0 +1,255 @@
+import { eq, inArray, sql } from "drizzle-orm";
+import {
+ syncMeta,
+ syncQueue,
+ workouts,
+ exercises,
+ setTemplates,
+ sessions,
+ loggedExercises,
+ loggedSets,
+ metricDefinitions,
+ metricEntries,
+} from "@ironlog/db/schema";
+
+const localTableRegistry: Record = {
+ workouts,
+ exercises,
+ setTemplates,
+ sessions,
+ loggedExercises,
+ loggedSets,
+ metricDefinitions,
+ metricEntries,
+};
+
+const timestampFields = ["createdAt", "updatedAt", "deletedAt", "startedAt", "finishedAt"];
+
+type DrizzleDb = {
+ select: (...args: any[]) => any;
+ insert: (...args: any[]) => any;
+ update: (...args: any[]) => any;
+ delete: (...args: any[]) => any;
+ transaction: (fn: (tx: DrizzleDb) => void) => void;
+};
+
+export interface ChangeItem {
+ table: string;
+ id: string;
+ data: Record;
+ updatedAt: number;
+ deletedAt?: number;
+}
+
+export interface PulledChange {
+ table: string;
+ [key: string]: unknown;
+}
+
+export interface SyncClient {
+ sync: {
+ push: { mutate(input: { changes: ChangeItem[] }): Promise<{ success: boolean }> };
+ pull: {
+ query(input: { cursor: number }): Promise<{ changes: PulledChange[]; cursor: number }>;
+ };
+ };
+}
+
+const CURSOR_KEY = "lastSyncCursor";
+
+export function getLastSyncCursor(db: DrizzleDb): number {
+ const row = db.select().from(syncMeta).where(eq(syncMeta.key, CURSOR_KEY)).get();
+ return row ? Number(row.value) : 0;
+}
+
+export function setLastSyncCursor(db: DrizzleDb, cursor: number): void {
+ db.insert(syncMeta)
+ .values({ key: CURSOR_KEY, value: String(cursor) })
+ .onConflictDoUpdate({
+ target: syncMeta.key,
+ set: { value: String(cursor) },
+ })
+ .run();
+}
+
+export async function pushChanges(db: DrizzleDb, client: SyncClient): Promise {
+ const entries = db.select().from(syncQueue).all();
+ if (entries.length === 0) return;
+
+ const changes: ChangeItem[] = entries.map((entry: any) => {
+ const data = JSON.parse(entry.payload);
+ return {
+ table: entry.tableName,
+ id: entry.recordId,
+ data,
+ updatedAt: entry.createdAt,
+ deletedAt: data.deletedAt,
+ };
+ });
+
+ const ids = entries.map((e: any) => e.id as string);
+
+ try {
+ await client.sync.push.mutate({ changes });
+ db.delete(syncQueue).where(inArray(syncQueue.id, ids)).run();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ db.update(syncQueue)
+ .set({
+ attempts: sql`${syncQueue.attempts} + 1`,
+ lastError: msg,
+ })
+ .where(inArray(syncQueue.id, ids))
+ .run();
+ throw err;
+ }
+}
+
+function prepareRowForInsert(change: PulledChange): Record {
+ const { table: _table, ...row } = change;
+ const result: Record = {};
+ for (const [key, value] of Object.entries(row)) {
+ if (timestampFields.includes(key) && typeof value === "number") {
+ result[key] = new Date(value);
+ } else {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
+export async function pullChanges(db: DrizzleDb, client: SyncClient): Promise {
+ const cursor = getLastSyncCursor(db);
+ const { changes, cursor: newCursor } = await client.sync.pull.query({ cursor });
+
+ for (const change of changes) {
+ const tableName = change.table as string;
+ const table = localTableRegistry[tableName];
+ if (!table) continue;
+
+ const row = prepareRowForInsert(change);
+ const id = row.id as string;
+
+ const existing = db.select().from(table).where(eq(table.id, id)).get();
+
+ if (!existing) {
+ db.insert(table).values(row).run();
+ } else {
+ const incomingUpdatedAt = typeof change.updatedAt === "number" ? change.updatedAt : 0;
+ const existingUpdatedAt =
+ existing.updatedAt instanceof Date
+ ? existing.updatedAt.getTime()
+ : (existing.updatedAt ?? 0);
+
+ if (incomingUpdatedAt > existingUpdatedAt) {
+ db.update(table).set(row).where(eq(table.id, id)).run();
+ }
+ }
+ }
+
+ if (newCursor > cursor) {
+ setLastSyncCursor(db, newCursor);
+ }
+}
+
+export function syncWrite(
+ db: DrizzleDb,
+ tableName: string,
+ operation: "insert" | "update" | "delete",
+ data: Record,
+): void {
+ const table = localTableRegistry[tableName];
+ if (!table) throw new Error(`Unknown table: ${tableName}`);
+
+ const now = new Date();
+ const nowMs = now.getTime();
+ const id = data.id as string;
+
+ db.transaction((tx: DrizzleDb) => {
+ if (operation === "insert") {
+ tx.insert(table)
+ .values({ ...data, createdAt: now, updatedAt: now })
+ .run();
+ } else if (operation === "update") {
+ tx.update(table)
+ .set({ ...data, updatedAt: now })
+ .where(eq(table.id, id))
+ .run();
+ } else if (operation === "delete") {
+ tx.update(table).set({ deletedAt: now, updatedAt: now }).where(eq(table.id, id)).run();
+ }
+
+ const payload: Record = { ...data, updatedAt: nowMs };
+ if (operation === "delete") {
+ payload.deletedAt = nowMs;
+ }
+
+ tx.insert(syncQueue)
+ .values({
+ id: crypto.randomUUID(),
+ tableName,
+ recordId: id,
+ operation,
+ payload: JSON.stringify(payload),
+ createdAt: nowMs,
+ })
+ .run();
+ });
+}
+
+const DEBOUNCE_MS = 1000;
+
+export function createSyncScheduler(syncFn: () => Promise) {
+ let timer: ReturnType | null = null;
+
+ function notifyWrite() {
+ if (timer != null) clearTimeout(timer);
+ timer = setTimeout(() => {
+ timer = null;
+ syncFn();
+ }, DEBOUNCE_MS);
+ }
+
+ function triggerNow() {
+ if (timer != null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ syncFn();
+ }
+
+ function cleanup() {
+ if (timer != null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ }
+
+ return { notifyWrite, triggerNow, cleanup };
+}
+
+function isUnauthorized(err: unknown): boolean {
+ if (err && typeof err === "object" && "code" in err) {
+ return (err as { code: string }).code === "UNAUTHORIZED";
+ }
+ return false;
+}
+
+export async function runSyncWithRetry(
+ db: DrizzleDb,
+ client: SyncClient,
+ refreshSession: () => Promise,
+): Promise {
+ try {
+ await pushChanges(db, client);
+ await pullChanges(db, client);
+ } catch (err) {
+ if (!isUnauthorized(err)) throw err;
+
+ await refreshSession();
+
+ // Retry once
+ await pushChanges(db, client);
+ await pullChanges(db, client);
+ }
+}
diff --git a/apps/native/lib/sync-provider.tsx b/apps/native/lib/sync-provider.tsx
new file mode 100644
index 0000000..0d42092
--- /dev/null
+++ b/apps/native/lib/sync-provider.tsx
@@ -0,0 +1,105 @@
+import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
+import { AppState } from "react-native";
+import * as Network from "expo-network";
+import NetInfo from "@react-native-community/netinfo";
+
+import { useLocalDb } from "./local-db-provider";
+import { createSyncScheduler } from "./sync-engine";
+
+type SyncStatus = "syncing" | "synced" | "offline";
+
+interface SyncContextValue {
+ status: SyncStatus;
+ notifyWrite: () => void;
+ triggerSync: () => void;
+}
+
+const SyncContext = createContext(null);
+
+export function useSyncStatus(): SyncContextValue {
+ const ctx = useContext(SyncContext);
+ if (!ctx) {
+ throw new Error("useSyncStatus must be used within a SyncProvider");
+ }
+ return ctx;
+}
+
+export function SyncProvider({ children }: { children: React.ReactNode }) {
+ const db = useLocalDb();
+ const [status, setStatus] = useState("syncing");
+ const isOnlineRef = useRef(true);
+
+ const performSync = useCallback(async () => {
+ if (!isOnlineRef.current) {
+ setStatus("offline");
+ return;
+ }
+ setStatus("syncing");
+ try {
+ // Placeholder: in production, pass real tRPC client + refreshSession
+ setStatus("synced");
+ } catch {
+ setStatus("synced");
+ }
+ }, [db]);
+
+ const schedulerRef = useRef | null>(null);
+
+ useEffect(() => {
+ const scheduler = createSyncScheduler(performSync);
+ schedulerRef.current = scheduler;
+ return () => scheduler.cleanup();
+ }, [performSync]);
+
+ // Check network on mount
+ useEffect(() => {
+ Network.getNetworkStateAsync().then((state) => {
+ const online = state.isConnected ?? false;
+ isOnlineRef.current = online;
+ if (!online) {
+ setStatus("offline");
+ } else {
+ performSync();
+ }
+ });
+ }, [performSync]);
+
+ // Real-time network changes via NetInfo
+ useEffect(() => {
+ const unsubscribe = NetInfo.addEventListener((state) => {
+ const online = state.isConnected ?? false;
+ isOnlineRef.current = online;
+ if (!online) {
+ setStatus("offline");
+ } else {
+ // Back online — trigger sync
+ schedulerRef.current?.triggerNow();
+ }
+ });
+ return unsubscribe;
+ }, []);
+
+ // Sync on app foreground
+ useEffect(() => {
+ const sub = AppState.addEventListener("change", (nextState) => {
+ if (nextState === "active" && isOnlineRef.current) {
+ schedulerRef.current?.triggerNow();
+ }
+ });
+ return () => sub.remove();
+ }, []);
+
+ const notifyWrite = useCallback(() => {
+ schedulerRef.current?.notifyWrite();
+ }, []);
+
+ const triggerSync = useCallback(() => {
+ schedulerRef.current?.triggerNow();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/native/lib/test-db-helper.ts b/apps/native/lib/test-db-helper.ts
index f1d8d66..a7d6c2c 100644
--- a/apps/native/lib/test-db-helper.ts
+++ b/apps/native/lib/test-db-helper.ts
@@ -10,17 +10,21 @@ export const MIGRATIONS_FOLDER = path.resolve(
"../../../packages/db/src/local-migrations",
);
-export const MIGRATION_SQL_PATH = path.resolve(MIGRATIONS_FOLDER, "0000_smooth_orphan.sql");
-
-/** Run raw SQL statements from the migration file (no migration tracking). */
+/** Run raw SQL statements from all migration .sql files (no migration tracking). */
export function runRawMigrations(db: InstanceType) {
- const sql = fs.readFileSync(MIGRATION_SQL_PATH, "utf-8");
- const statements = sql
- .split("--> statement-breakpoint")
- .map((s) => s.trim())
- .filter(Boolean);
- for (const stmt of statements) {
- db.exec(stmt);
+ const sqlFiles = fs
+ .readdirSync(MIGRATIONS_FOLDER)
+ .filter((f) => f.endsWith(".sql"))
+ .sort();
+ for (const file of sqlFiles) {
+ const sql = fs.readFileSync(path.resolve(MIGRATIONS_FOLDER, file), "utf-8");
+ const statements = sql
+ .split("--> statement-breakpoint")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ for (const stmt of statements) {
+ db.exec(stmt);
+ }
}
}
diff --git a/apps/native/package.json b/apps/native/package.json
index d840c34..0893803 100644
--- a/apps/native/package.json
+++ b/apps/native/package.json
@@ -23,6 +23,7 @@
"@ironlog/api": "workspace:*",
"@ironlog/db": "workspace:*",
"@ironlog/env": "workspace:*",
+ "@react-native-community/netinfo": "^12.0.1",
"@react-navigation/bottom-tabs": "^7.15.2",
"@react-navigation/elements": "^2.8.1",
"@tanstack/react-form": "^1.28.0",
diff --git a/packages/db/src/local-migrations/0001_gorgeous_nuke.sql b/packages/db/src/local-migrations/0001_gorgeous_nuke.sql
new file mode 100644
index 0000000..b277976
--- /dev/null
+++ b/packages/db/src/local-migrations/0001_gorgeous_nuke.sql
@@ -0,0 +1,15 @@
+CREATE TABLE `_sync_meta` (
+ `key` text PRIMARY KEY NOT NULL,
+ `value` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `_sync_queue` (
+ `id` text PRIMARY KEY NOT NULL,
+ `table_name` text NOT NULL,
+ `record_id` text NOT NULL,
+ `operation` text NOT NULL,
+ `payload` text NOT NULL,
+ `created_at` integer NOT NULL,
+ `attempts` integer DEFAULT 0 NOT NULL,
+ `last_error` text
+);
diff --git a/packages/db/src/local-migrations/meta/0001_snapshot.json b/packages/db/src/local-migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..f29498a
--- /dev/null
+++ b/packages/db/src/local-migrations/meta/0001_snapshot.json
@@ -0,0 +1,1193 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "094c89e0-b162-490a-9a5a-7739c9bdca92",
+ "prevId": "d068d5a7-89c7-4c40-a33d-45d2d842aa4c",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "account_userId_idx": {
+ "name": "account_userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "columns": ["token"],
+ "isUnique": true
+ },
+ "session_userId_idx": {
+ "name": "session_userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": ["email"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verification": {
+ "name": "verification",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsecond') * 1000 as integer))"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": ["identifier"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "exercises": {
+ "name": "exercises",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workout_id": {
+ "name": "workout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "exercises_workout_id_idx": {
+ "name": "exercises_workout_id_idx",
+ "columns": ["workout_id"],
+ "isUnique": false
+ },
+ "exercises_user_id_idx": {
+ "name": "exercises_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "exercises_user_id_user_id_fk": {
+ "name": "exercises_user_id_user_id_fk",
+ "tableFrom": "exercises",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "exercises_workout_id_workouts_id_fk": {
+ "name": "exercises_workout_id_workouts_id_fk",
+ "tableFrom": "exercises",
+ "tableTo": "workouts",
+ "columnsFrom": ["workout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "set_templates": {
+ "name": "set_templates",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exercise_id": {
+ "name": "exercise_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_reps": {
+ "name": "target_reps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "set_templates_exercise_id_idx": {
+ "name": "set_templates_exercise_id_idx",
+ "columns": ["exercise_id"],
+ "isUnique": false
+ },
+ "set_templates_user_id_idx": {
+ "name": "set_templates_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "set_templates_user_id_user_id_fk": {
+ "name": "set_templates_user_id_user_id_fk",
+ "tableFrom": "set_templates",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "set_templates_exercise_id_exercises_id_fk": {
+ "name": "set_templates_exercise_id_exercises_id_fk",
+ "tableFrom": "set_templates",
+ "tableTo": "exercises",
+ "columnsFrom": ["exercise_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "workouts": {
+ "name": "workouts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "workouts_user_id_idx": {
+ "name": "workouts_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "workouts_user_id_user_id_fk": {
+ "name": "workouts_user_id_user_id_fk",
+ "tableFrom": "workouts",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "logged_exercises": {
+ "name": "logged_exercises",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exercise_id": {
+ "name": "exercise_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "logged_exercises_session_id_idx": {
+ "name": "logged_exercises_session_id_idx",
+ "columns": ["session_id"],
+ "isUnique": false
+ },
+ "logged_exercises_user_id_idx": {
+ "name": "logged_exercises_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "logged_exercises_user_id_user_id_fk": {
+ "name": "logged_exercises_user_id_user_id_fk",
+ "tableFrom": "logged_exercises",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "logged_exercises_session_id_sessions_id_fk": {
+ "name": "logged_exercises_session_id_sessions_id_fk",
+ "tableFrom": "logged_exercises",
+ "tableTo": "sessions",
+ "columnsFrom": ["session_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "logged_sets": {
+ "name": "logged_sets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "logged_exercise_id": {
+ "name": "logged_exercise_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "weight": {
+ "name": "weight",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "target_reps": {
+ "name": "target_reps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "actual_reps": {
+ "name": "actual_reps",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "done": {
+ "name": "done",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "logged_sets_logged_exercise_id_idx": {
+ "name": "logged_sets_logged_exercise_id_idx",
+ "columns": ["logged_exercise_id"],
+ "isUnique": false
+ },
+ "logged_sets_user_id_idx": {
+ "name": "logged_sets_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "logged_sets_user_id_user_id_fk": {
+ "name": "logged_sets_user_id_user_id_fk",
+ "tableFrom": "logged_sets",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "logged_sets_logged_exercise_id_logged_exercises_id_fk": {
+ "name": "logged_sets_logged_exercise_id_logged_exercises_id_fk",
+ "tableFrom": "logged_sets",
+ "tableTo": "logged_exercises",
+ "columnsFrom": ["logged_exercise_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sessions": {
+ "name": "sessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "workout_id": {
+ "name": "workout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "workout_title": {
+ "name": "workout_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ },
+ "sessions_workout_id_idx": {
+ "name": "sessions_workout_id_idx",
+ "columns": ["workout_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_user_id_fk": {
+ "name": "sessions_user_id_user_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "sessions_workout_id_workouts_id_fk": {
+ "name": "sessions_workout_id_workouts_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "workouts",
+ "columnsFrom": ["workout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "metric_definitions": {
+ "name": "metric_definitions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "metric_definitions_user_id_idx": {
+ "name": "metric_definitions_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "metric_definitions_user_id_user_id_fk": {
+ "name": "metric_definitions_user_id_user_id_fk",
+ "tableFrom": "metric_definitions",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "metric_entries": {
+ "name": "metric_entries",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric_definition_id": {
+ "name": "metric_definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "date": {
+ "name": "date",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(cast(unixepoch('subsec') * 1000 as integer))"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "metric_entries_user_id_idx": {
+ "name": "metric_entries_user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ },
+ "metric_entries_definition_id_idx": {
+ "name": "metric_entries_definition_id_idx",
+ "columns": ["metric_definition_id"],
+ "isUnique": false
+ },
+ "metric_entries_definition_date_idx": {
+ "name": "metric_entries_definition_date_idx",
+ "columns": ["metric_definition_id", "date"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "metric_entries_user_id_user_id_fk": {
+ "name": "metric_entries_user_id_user_id_fk",
+ "tableFrom": "metric_entries",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "metric_entries_metric_definition_id_metric_definitions_id_fk": {
+ "name": "metric_entries_metric_definition_id_metric_definitions_id_fk",
+ "tableFrom": "metric_entries",
+ "tableTo": "metric_definitions",
+ "columnsFrom": ["metric_definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "_sync_meta": {
+ "name": "_sync_meta",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "_sync_queue": {
+ "name": "_sync_queue",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "table_name": {
+ "name": "table_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "record_id": {
+ "name": "record_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "operation": {
+ "name": "operation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/packages/db/src/local-migrations/meta/_journal.json b/packages/db/src/local-migrations/meta/_journal.json
index fbc39c3..a3c65d9 100644
--- a/packages/db/src/local-migrations/meta/_journal.json
+++ b/packages/db/src/local-migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1772355569259,
"tag": "0000_smooth_orphan",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1772357593258,
+ "tag": "0001_gorgeous_nuke",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/local-migrations/migrations.js b/packages/db/src/local-migrations/migrations.js
index 2b8c456..53181e6 100644
--- a/packages/db/src/local-migrations/migrations.js
+++ b/packages/db/src/local-migrations/migrations.js
@@ -2,10 +2,12 @@
import journal from "./meta/_journal.json";
import m0000 from "./0000_smooth_orphan.sql";
+import m0001 from "./0001_gorgeous_nuke.sql";
export default {
journal,
migrations: {
m0000,
+ m0001,
},
};
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index 065fe09..32a53df 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -2,3 +2,4 @@ export * from "./auth";
export * from "./workouts";
export * from "./sessions";
export * from "./metrics";
+export * from "./sync";
diff --git a/packages/db/src/schema/sync.ts b/packages/db/src/schema/sync.ts
new file mode 100644
index 0000000..f388446
--- /dev/null
+++ b/packages/db/src/schema/sync.ts
@@ -0,0 +1,17 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
+
+export const syncQueue = sqliteTable("_sync_queue", {
+ id: text("id").primaryKey(),
+ tableName: text("table_name").notNull(),
+ recordId: text("record_id").notNull(),
+ operation: text("operation", { enum: ["insert", "update", "delete"] }).notNull(),
+ payload: text("payload").notNull(),
+ createdAt: integer("created_at").notNull(),
+ attempts: integer("attempts").notNull().default(0),
+ lastError: text("last_error"),
+});
+
+export const syncMeta = sqliteTable("_sync_meta", {
+ key: text("key").primaryKey(),
+ value: text("value").notNull(),
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec282b3..feccf25 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -105,6 +105,9 @@ importers:
'@ironlog/env':
specifier: workspace:*
version: link:../../packages/env
+ '@react-native-community/netinfo':
+ specifier: ^12.0.1
+ version: 12.0.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-navigation/bottom-tabs':
specifier: ^7.15.2
version: 7.15.2(@react-navigation/native@7.1.31(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-safe-area-context@5.6.2(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-screens@4.23.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)
@@ -282,7 +285,7 @@ importers:
version: 11.10.0(typescript@5.9.3)
better-auth:
specifier: 'catalog:'
- version: 1.4.19(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
+ version: 1.4.19(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
dotenv:
specifier: 'catalog:'
version: 17.3.1
@@ -295,7 +298,7 @@ importers:
devDependencies:
'@cloudflare/vitest-pool-workers':
specifier: ^0.12.18
- version: 0.12.18(@cloudflare/workers-types@4.20260226.1)(@vitest/runner@4.0.18)(@vitest/snapshot@4.0.18)(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
+ version: 0.12.18(@cloudflare/workers-types@4.20260226.1)(@vitest/runner@4.0.18)(@vitest/snapshot@4.0.18)(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
'@cloudflare/workers-types':
specifier: 'catalog:'
version: 4.20260226.1
@@ -307,7 +310,7 @@ importers:
version: 22.19.13
'@vitest/coverage-istanbul':
specifier: ^3.2.4
- version: 3.2.4(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
+ version: 3.2.4(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
alchemy:
specifier: 'catalog:'
version: 0.82.2(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11)(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(workerd@1.20260305.0)(wrangler@4.69.0(@cloudflare/workers-types@4.20260226.1))
@@ -2698,6 +2701,12 @@ packages:
'@types/react':
optional: true
+ '@react-native-community/netinfo@12.0.1':
+ resolution: {integrity: sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==}
+ peerDependencies:
+ react: '*'
+ react-native: '>=0.59'
+
'@react-native/assets-registry@0.83.2':
resolution: {integrity: sha512-9I5l3pGAKnlpQ15uVkeB9Mgjvt3cZEaEc8EDtdexvdtZvLSjtwBzgourrOW4yZUijbjJr8h3YO2Y0q+THwUHTA==}
engines: {node: '>= 20.19.4'}
@@ -8741,14 +8750,14 @@ snapshots:
optionalDependencies:
workerd: 1.20260305.0
- '@cloudflare/vitest-pool-workers@0.12.18(@cloudflare/workers-types@4.20260226.1)(@vitest/runner@4.0.18)(@vitest/snapshot@4.0.18)(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
+ '@cloudflare/vitest-pool-workers@0.12.18(@cloudflare/workers-types@4.20260226.1)(@vitest/runner@4.0.18)(@vitest/snapshot@4.0.18)(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
cjs-module-lexer: 1.4.3
esbuild: 0.27.3
miniflare: 4.20260305.0
- vitest: 4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
wrangler: 4.69.0(@cloudflare/workers-types@4.20260226.1)
transitivePeerDependencies:
- '@cloudflare/workers-types'
@@ -10165,6 +10174,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ '@react-native-community/netinfo@12.0.1(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ react-native: 0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
+
'@react-native/assets-registry@0.83.2': {}
'@react-native/babel-plugin-codegen@0.83.2(@babel/core@7.29.0)':
@@ -11081,7 +11095,7 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitest/coverage-istanbul@3.2.4(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
+ '@vitest/coverage-istanbul@3.2.4(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@istanbuljs/schema': 0.1.3
debug: 4.4.3
@@ -11093,7 +11107,7 @@ snapshots:
magicast: 0.3.5
test-exclude: 7.0.2
tinyrainbow: 2.0.0
- vitest: 4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
@@ -11113,6 +11127,7 @@ snapshots:
'@vitest/utils': 4.0.18
chai: 6.2.2
tinyrainbow: 3.0.3
+ optional: true
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
@@ -11122,13 +11137,14 @@ snapshots:
optionalDependencies:
vite: 7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
- '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
+ '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+ vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+ optional: true
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -11165,7 +11181,8 @@ snapshots:
dependencies:
tinyspy: 4.0.4
- '@vitest/spy@4.0.18': {}
+ '@vitest/spy@4.0.18':
+ optional: true
'@vitest/utils@3.2.4':
dependencies:
@@ -11555,7 +11572,7 @@ snapshots:
before-after-hook@3.0.2: {}
- better-auth@1.4.19(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)):
+ better-auth@1.4.19(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)):
dependencies:
'@better-auth/core': 1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1)
'@better-auth/telemetry': 1.4.19(@better-auth/core@1.4.19(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.1))
@@ -11575,7 +11592,7 @@ snapshots:
drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- vitest: 4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
+ vitest: 3.2.4(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
better-auth@1.4.19(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260226.1)(@libsql/client@0.15.15)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2)(expo-sqlite@55.0.10(expo@55.0.3)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(kysely@0.28.11))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.0.18(@types/node@24.10.15)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)):
dependencies:
@@ -11718,7 +11735,8 @@ snapshots:
loupe: 3.2.1
pathval: 2.0.1
- chai@6.2.2: {}
+ chai@6.2.2:
+ optional: true
chalk@2.4.2:
dependencies:
@@ -15497,48 +15515,10 @@ snapshots:
- tsx
- yaml
- vitest@4.0.18(@types/node@22.19.13)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2):
- dependencies:
- '@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
- '@vitest/pretty-format': 4.0.18
- '@vitest/runner': 4.0.18
- '@vitest/snapshot': 4.0.18
- '@vitest/spy': 4.0.18
- '@vitest/utils': 4.0.18
- es-module-lexer: 1.7.0
- expect-type: 1.3.0
- magic-string: 0.30.21
- obug: 2.1.1
- pathe: 2.0.3
- picomatch: 4.0.3
- std-env: 3.10.0
- tinybench: 2.9.0
- tinyexec: 1.0.2
- tinyglobby: 0.2.15
- tinyrainbow: 3.0.3
- vite: 7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2)
- why-is-node-running: 2.3.0
- optionalDependencies:
- '@types/node': 22.19.13
- jsdom: 20.0.3
- transitivePeerDependencies:
- - jiti
- - less
- - lightningcss
- - msw
- - sass
- - sass-embedded
- - stylus
- - sugarss
- - terser
- - tsx
- - yaml
-
vitest@4.0.18(@types/node@24.10.15)(jiti@2.6.1)(jsdom@20.0.3)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
+ '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18