-
Notifications
You must be signed in to change notification settings - Fork 0
Refactor: Treat local store as a cache, never overwriting DB on sync #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f70e8aa
8e0603c
4a7d75d
3555600
e7608d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,52 +1,186 @@ | ||||||||||
| import type { SupabaseClient, RealtimeChannel } from "@supabase/supabase-js"; | ||||||||||
| import { retryWithBackoff } from "./noteService"; | ||||||||||
| import { deepEqual } from "@/lib/utils"; | ||||||||||
|
|
||||||||||
| export interface SyncConfig<Local> { | ||||||||||
| export interface Identifiable { | ||||||||||
| id: string; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface SyncConfig<Local extends Identifiable> { | ||||||||||
| table: string; | ||||||||||
| mapRow: (row: any) => Local; | ||||||||||
| mapLocal: (item: Local, userId: string) => Record<string, unknown>; | ||||||||||
| updatedAtColumn?: string; | ||||||||||
| /** | ||||||||||
| * Page size for fetching records. Defaults to 1000. | ||||||||||
| * Adjust based on your record size and performance needs. | ||||||||||
| */ | ||||||||||
| pageSize?: number; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export class SyncManager<Local> { | ||||||||||
| export class SyncManager<Local extends Identifiable> { | ||||||||||
| constructor(private supabase: SupabaseClient, private cfg: SyncConfig<Local>) {} | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Sync localItems to remote and fetch remote items. | ||||||||||
| * @param since ISO timestamp to filter remote updates (inclusive) | ||||||||||
| */ | ||||||||||
| async sync(userId: string, localItems: Local[], since?: string): Promise<Local[]> { | ||||||||||
| // upsert local items | ||||||||||
| if (localItems.length) { | ||||||||||
| await retryWithBackoff(async () => { | ||||||||||
| const payload = localItems.map(item => this.cfg.mapLocal(item, userId)); | ||||||||||
| const { error } = await this.supabase | ||||||||||
| .from(this.cfg.table) | ||||||||||
| .upsert(payload, { onConflict: "id" }); | ||||||||||
| if (error) throw error; | ||||||||||
| }); | ||||||||||
| // 1. Fetch remote items with incremental sync support | ||||||||||
| const pageSize = this.cfg.pageSize || 1000; | ||||||||||
| let cursor = since ?? undefined; | ||||||||||
| let allRemoteRows: any[] = []; | ||||||||||
|
|
||||||||||
| while (true) { | ||||||||||
| const updatedCol = this.cfg.updatedAtColumn || 'updated_at'; | ||||||||||
| let query = this.supabase | ||||||||||
| .from(this.cfg.table) | ||||||||||
| .select('*') | ||||||||||
| .eq('user_id', userId) | ||||||||||
| .order(updatedCol, { ascending: true }) | ||||||||||
| .limit(pageSize); | ||||||||||
|
|
||||||||||
| if (cursor) { | ||||||||||
| query = query.gt(updatedCol, cursor); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const { data: pageData, error: fetchError } = await query; | ||||||||||
|
|
||||||||||
| if (fetchError) throw fetchError; | ||||||||||
|
|
||||||||||
| if (!pageData || pageData.length === 0) { | ||||||||||
| break; // No more data | ||||||||||
| } | ||||||||||
|
|
||||||||||
| allRemoteRows = [...allRemoteRows, ...pageData]; | ||||||||||
|
|
||||||||||
| // Update cursor for the next page | ||||||||||
| cursor = pageData[pageData.length - 1][updatedCol]; | ||||||||||
|
|
||||||||||
| // If we got fewer items than requested, we've reached the end | ||||||||||
| if (pageData.length < pageSize) { | ||||||||||
| break; | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // fetch remote items (optionally incremental) | ||||||||||
| let query = this.supabase | ||||||||||
| .from(this.cfg.table) | ||||||||||
| .select("*") | ||||||||||
| .eq("user_id", userId); | ||||||||||
| if (since && this.cfg.updatedAtColumn) { | ||||||||||
| query = query.gt(this.cfg.updatedAtColumn, since); | ||||||||||
| const remoteRows = allRemoteRows; | ||||||||||
| const remoteItems = remoteRows.map(r => this.cfg.mapRow(r)); | ||||||||||
| const remoteMap = new Map(remoteItems.map(item => [item.id, item])); | ||||||||||
| const localMap = new Map(localItems.map(item => [item.id, item])); | ||||||||||
|
|
||||||||||
| // 2. Categorize items | ||||||||||
| const localOnlyItems: Local[] = []; | ||||||||||
| const remoteOnlyItems: Local[] = []; | ||||||||||
| const modifiedItems: { local: Local; remote: Local; isLocalNewer: boolean }[] = []; | ||||||||||
|
|
||||||||||
| // Check local items first | ||||||||||
| for (const localItem of localItems) { | ||||||||||
| const remoteItem = remoteMap.get(localItem.id); | ||||||||||
| if (!remoteItem) { | ||||||||||
| localOnlyItems.push(localItem); | ||||||||||
| } else { | ||||||||||
| // Check if local item is modified by comparing content, not just timestamps | ||||||||||
| const { [this.cfg.updatedAtColumn || 'updatedAt']: localUpdatedAt, ...localWithoutTs } = localItem as any; | ||||||||||
| const { [this.cfg.updatedAtColumn || 'updatedAt']: remoteUpdatedAt, ...remoteWithoutTs } = remoteItem as any; | ||||||||||
|
|
||||||||||
| // Use deep equality check instead of JSON.stringify for accurate object comparison | ||||||||||
| if (!deepEqual(localWithoutTs, remoteWithoutTs)) { | ||||||||||
| // Default to treating items with missing timestamps as older than any item with a valid timestamp | ||||||||||
| const localTimeRaw = localUpdatedAt ? new Date(localUpdatedAt).getTime() : Number.NEGATIVE_INFINITY; | ||||||||||
| const localTime = Number.isNaN(localTimeRaw) ? Number.NEGATIVE_INFINITY : localTimeRaw; | ||||||||||
| const remoteTimeRaw = remoteUpdatedAt ? new Date(remoteUpdatedAt).getTime() : Number.NEGATIVE_INFINITY; | ||||||||||
| const remoteTime = Number.isFinite(remoteTimeRaw) ? remoteTimeRaw : Number.NEGATIVE_INFINITY; | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Incorrect ternary expression for remoteTime calculation The current expression always evaluates to a boolean instead of the timestamp value. - const remoteTime = Number.isFinite(remoteTimeRaw) ? Number.isFinite(remoteTimeRaw) : Number.NEGATIVE_INFINITY;
+ const remoteTime = Number.isFinite(remoteTimeRaw) ? remoteTimeRaw : Number.NEGATIVE_INFINITY;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| const isLocalNewer = localTime > remoteTime; | ||||||||||
|
|
||||||||||
| modifiedItems.push({ | ||||||||||
| local: localItem, | ||||||||||
| remote: remoteItem, | ||||||||||
| isLocalNewer | ||||||||||
| }); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Find remote-only items | ||||||||||
| for (const remoteItem of remoteItems) { | ||||||||||
| if (!localMap.has(remoteItem.id)) { | ||||||||||
| remoteOnlyItems.push(remoteItem); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| const { data: rows, error } = await query; | ||||||||||
| if (error) throw error; | ||||||||||
| const remoteRows = rows || []; | ||||||||||
|
|
||||||||||
| // map rows to Local | ||||||||||
| const remoteItems = (remoteRows as any[]).map(r => this.cfg.mapRow(r)); | ||||||||||
| // 3. Handle local-only items (new items) | ||||||||||
| if (localOnlyItems.length > 0) { | ||||||||||
| try { | ||||||||||
| await retryWithBackoff(async () => { | ||||||||||
| const payload = localOnlyItems.map(item => this.cfg.mapLocal(item, userId)); | ||||||||||
| const { error: insertError } = await this.supabase | ||||||||||
| .from(this.cfg.table) | ||||||||||
| .insert(payload); | ||||||||||
|
|
||||||||||
| if (insertError) throw insertError; | ||||||||||
| }); | ||||||||||
| } catch (insertError) { | ||||||||||
| console.warn( | ||||||||||
| `[SyncManager] Failed to insert local-only items for table ${this.cfg.table} after retries: ${(insertError as Error).message}`, | ||||||||||
| localOnlyItems | ||||||||||
| ); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // detect local-only items | ||||||||||
| const remoteIds = new Set(remoteItems.map((item: any) => (item as any).id)); | ||||||||||
| const localOnly = localItems.filter(item => !remoteIds.has((item as any).id)); | ||||||||||
| // 4. Handle modified items with conflict resolution | ||||||||||
| if (modifiedItems.length > 0) { | ||||||||||
| try { | ||||||||||
| await retryWithBackoff(async () => { | ||||||||||
| const updates = []; | ||||||||||
| const now = new Date().toISOString(); | ||||||||||
|
|
||||||||||
| for (const { local, remote, isLocalNewer } of modifiedItems) { | ||||||||||
| // Only update if local changes are actually newer or if there are content changes | ||||||||||
| if (isLocalNewer) { | ||||||||||
| const updateData = this.cfg.mapLocal(local, userId); | ||||||||||
| // Ensure updated_at is set to now for the last-write-wins strategy | ||||||||||
| if (this.cfg.updatedAtColumn) { | ||||||||||
| updateData[this.cfg.updatedAtColumn] = now; | ||||||||||
| // Also update the local object's timestamp to match | ||||||||||
| (local as any)[this.cfg.updatedAtColumn] = now; | ||||||||||
| } | ||||||||||
| updates.push(updateData); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return [...remoteItems, ...localOnly]; | ||||||||||
| if (updates.length > 0) { | ||||||||||
| const { error: updateError } = await this.supabase | ||||||||||
| .from(this.cfg.table) | ||||||||||
| .upsert(updates, { onConflict: 'id' }); | ||||||||||
|
|
||||||||||
| if (updateError) throw updateError; | ||||||||||
| } | ||||||||||
| }); | ||||||||||
| } catch (updateError) { | ||||||||||
| console.warn( | ||||||||||
| `[SyncManager] Failed to update modified items for table ${this.cfg.table}: ${(updateError as Error).message}`, | ||||||||||
| modifiedItems | ||||||||||
| ); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // 5. Return combined results including unchanged items | ||||||||||
| const mergedItems = [...remoteOnlyItems, ...localOnlyItems]; | ||||||||||
|
|
||||||||||
| // Add modified items (either local or remote version) | ||||||||||
| for (const { local, remote, isLocalNewer } of modifiedItems) { | ||||||||||
| mergedItems.push(isLocalNewer ? local : remote); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Add unchanged items (present in both local and remote but with identical content) | ||||||||||
| const processedIds = new Set(mergedItems.map(item => item.id)); | ||||||||||
| for (const remoteItem of remoteItems) { | ||||||||||
| if (!processedIds.has(remoteItem.id)) { | ||||||||||
| mergedItems.push(remoteItem); | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| return mergedItems; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| subscribe( | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Excellent type system improvements
Creating the
Identifiableinterface and constraining the generic type parameter makes the code more type-safe and self-documenting. The addition of thepageSizeparameter with documentation is also a good enhancement.There's still an
anytype in themapRowfunction that could be improved:🏁 Script executed:
Length of output: 3941
Use a strongly-typed row parameter in
mapRowWhile the new
Identifiableconstraint andpageSizedocs are great, themapRow: (row: any) => Localstill relies onany, which erodes type safety. Please introduce a generic for the remote row type and replaceanywith a well-defined type.• In
src/services/SyncManager.ts, update the interface:• Adjust existing usages:
src/store/noteStore.ts— replace(row: any)with(row: NoteRow)orRecord<string, unknown>.src/services/noteService.ts— similarly type therowparameter.This change avoids
anyand makes eachmapRowimplementation explicitly typed.📝 Committable suggestion
🧰 Tools
🪛 ESLint
[error] 11-11: Unexpected any. Specify a different type.
(@typescript-eslint/no-explicit-any)
🤖 Prompt for AI Agents