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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,40 @@ export function debounce<T extends (...args: unknown[]) => unknown>(
timeout = setTimeout(later, wait);
};
}

export function deepEqual(obj1: unknown, obj2: unknown): boolean {
const seen = new WeakMap<object, object>();
function inner(a: unknown, b: unknown): boolean {
// primitive / reference
if (a === b) return true;
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false;

if (seen.get(a as object) === b) return true;
seen.set(a as object, b as object);

if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;

// Handle built-ins that need value-level equality
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
if (a instanceof RegExp && b instanceof RegExp)
return a.source === b.source && a.flags === b.flags;
if (a instanceof Map && b instanceof Map)
return a.size === b.size && [...a.entries()].every(([k, v]) => b.has(k) && inner(v, b.get(k)));
if (a instanceof Set && b instanceof Set)
return a.size === b.size && [...a].every(v => b.has(v));

const keysA = Reflect.ownKeys(a as object);
const keysB = Reflect.ownKeys(b as object);
if (keysA.length !== keysB.length) return false;

return keysA.every(key =>
keysB.includes(key) &&
inner(
(a as Record<PropertyKey, unknown>)[key],
(b as Record<PropertyKey, unknown>)[key]
)
);
}

return inner(obj1, obj2);
}
188 changes: 161 additions & 27 deletions src/services/SyncManager.ts
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;
}
Comment on lines +5 to 19
Copy link
Copy Markdown
Contributor

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 Identifiable interface and constraining the generic type parameter makes the code more type-safe and self-documenting. The addition of the pageSize parameter with documentation is also a good enhancement.

There's still an any type in the mapRow function that could be improved:


🏁 Script executed:

#!/bin/bash
# Check how this function is used in practice
rg "mapRow" --type ts -A 5 -B 5 src/

Length of output: 3941


Use a strongly-typed row parameter in mapRow

While the new Identifiable constraint and pageSize docs are great, the mapRow: (row: any) => Local still relies on any, which erodes type safety. Please introduce a generic for the remote row type and replace any with a well-defined type.

• In src/services/SyncManager.ts, update the interface:

-export interface SyncConfig<Local extends Identifiable> {
-  table: string;
-  mapRow: (row: any) => Local;
+export interface SyncConfig<
+  Local extends Identifiable,
+  Remote = Record<string, unknown>
+> {
+  table: string;
+  mapRow: (row: Remote) => Local;
   mapLocal: (item: Local, userId: string) => Record<string, unknown>;
   updatedAtColumn?: string;
   pageSize?: number;
}

• Adjust existing usages:

  • src/store/noteStore.ts — replace (row: any) with (row: NoteRow) or Record<string, unknown>.
  • src/services/noteService.ts — similarly type the row parameter.

This change avoids any and makes each mapRow implementation explicitly typed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 interface Identifiable {
id: string;
}
export interface SyncConfig<
Local extends Identifiable,
Remote = Record<string, unknown>
> {
table: string;
mapRow: (row: Remote) => 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;
}
🧰 Tools
🪛 ESLint

[error] 11-11: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
In src/services/SyncManager.ts lines 5 to 19, the mapRow function parameter is
typed as any, reducing type safety. Introduce a new generic type parameter for
the remote row type in the SyncConfig interface and replace the any type in
mapRow with this generic type. Then update all existing usages of SyncConfig,
such as in src/store/noteStore.ts and src/services/noteService.ts, to specify
the appropriate row type instead of any, ensuring each mapRow implementation
uses a strongly typed parameter.


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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const remoteTime = Number.isFinite(remoteTimeRaw) ? remoteTimeRaw : Number.NEGATIVE_INFINITY;
// before: returns a boolean instead of the timestamp
- const remoteTime = Number.isFinite(remoteTimeRaw) ? Number.isFinite(remoteTimeRaw) : Number.NEGATIVE_INFINITY;
+ const remoteTime = Number.isFinite(remoteTimeRaw) ? remoteTimeRaw : Number.NEGATIVE_INFINITY;
🤖 Prompt for AI Agents
In src/services/SyncManager.ts at line 92, the ternary expression for remoteTime
is incorrect because it uses Number.isFinite(remoteTimeRaw) directly, which
returns a boolean, causing remoteTime to be set to true or false instead of the
intended numeric timestamp. Fix this by checking if remoteTimeRaw is a finite
number and if so, assign remoteTimeRaw itself; otherwise, assign
Number.NEGATIVE_INFINITY.

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(
Expand Down