From 9b68975c343fbdfd9b4b71859de7f35f4ea44a2e Mon Sep 17 00:00:00 2001 From: Joydeep Singha Date: Sun, 1 Mar 2026 15:13:33 +0530 Subject: [PATCH] Add client sync engine with SyncProvider (#49) Closes #49 - Add _sync_queue and _sync_meta Drizzle tables with local migration - Implement sync-engine: pushChanges, pullChanges with LWW conflict resolution, syncWrite (atomic local write + queue enqueue), debounced scheduler, and 401 refresh-retry logic - Add SyncProvider with syncing/synced/offline status, network monitoring (expo-network + NetInfo), and AppState foreground sync - Wrap root layout with SyncProvider - Update test-db-helper to glob all migration .sql files - Add @react-native-community/netinfo dependency Co-Authored-By: Claude Opus 4.6 --- apps/native/app/__tests__/_layout.test.tsx | 3 + apps/native/app/_layout.tsx | 29 +- .../lib/__tests__/sync-auth-retry.test.ts | 67 + .../lib/__tests__/sync-debounce.test.ts | 50 + apps/native/lib/__tests__/sync-lww.test.ts | 102 ++ apps/native/lib/__tests__/sync-meta.test.ts | 22 + .../lib/__tests__/sync-provider.test.tsx | 94 ++ apps/native/lib/__tests__/sync-pull.test.ts | 53 + apps/native/lib/__tests__/sync-push.test.ts | 79 ++ apps/native/lib/__tests__/sync-queue.test.ts | 34 + apps/native/lib/__tests__/sync-write.test.ts | 89 ++ apps/native/lib/sync-engine.ts | 255 ++++ apps/native/lib/sync-provider.tsx | 105 ++ apps/native/lib/test-db-helper.ts | 24 +- apps/native/package.json | 1 + .../local-migrations/0001_gorgeous_nuke.sql | 15 + .../local-migrations/meta/0001_snapshot.json | 1193 +++++++++++++++++ .../src/local-migrations/meta/_journal.json | 7 + .../db/src/local-migrations/migrations.js | 2 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/sync.ts | 17 + pnpm-lock.yaml | 84 +- 22 files changed, 2251 insertions(+), 75 deletions(-) create mode 100644 apps/native/lib/__tests__/sync-auth-retry.test.ts create mode 100644 apps/native/lib/__tests__/sync-debounce.test.ts create mode 100644 apps/native/lib/__tests__/sync-lww.test.ts create mode 100644 apps/native/lib/__tests__/sync-meta.test.ts create mode 100644 apps/native/lib/__tests__/sync-provider.test.tsx create mode 100644 apps/native/lib/__tests__/sync-pull.test.ts create mode 100644 apps/native/lib/__tests__/sync-push.test.ts create mode 100644 apps/native/lib/__tests__/sync-queue.test.ts create mode 100644 apps/native/lib/__tests__/sync-write.test.ts create mode 100644 apps/native/lib/sync-engine.ts create mode 100644 apps/native/lib/sync-provider.tsx create mode 100644 packages/db/src/local-migrations/0001_gorgeous_nuke.sql create mode 100644 packages/db/src/local-migrations/meta/0001_snapshot.json create mode 100644 packages/db/src/schema/sync.ts 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