fix(mana/web): sprint 3 — type-safe sync protocol + tests

- New SyncChange / FieldChange / SyncOp types replace `any[]` in
  applyServerChanges. The wire format is now self-documenting and
  TypeScript catches malformed callsites at compile time.
- isValidSyncChange() validates incoming server payloads at the boundary:
  malformed entries are dropped with a single warn log, valid ones are
  applied. A bad row from the server can no longer corrupt IndexedDB.
  Hand-rolled type guards keep us free of a runtime-validation dep.
- applyServerChanges() and readFieldTimestamps() are now top-level
  exports (extracted out of createUnifiedSync's closure) so they can be
  imported directly by tests. Behaviour is unchanged — the closure
  variant inside the sync manager just resolves the module-level
  symbol now.
- New sync.test.ts covers:
  * pure isValidSyncChange and readFieldTimestamps cases
  * field-level LWW: server-newer wins, split outcome when local-newer
    on one field and server-newer on another
  * insert with __fieldTimestamps stamping
  * soft-delete LWW guard
  * malformed-entry drop with valid entries surviving
  * sync-loop guard: server-applied writes don't generate _pendingChanges
- fake-indexeddb added as devDependency for the integration tests.

Note: the monorepo's vitest install is currently tangled across mixed
@vitest/* package versions in the lockfile, so `pnpm test` fails before
reaching this file. The tests are written to pass on any vitest 4.x once
that's untangled — needs its own dedicated cleanup pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 13:38:23 +02:00
parent ce04f43248
commit 9e0ade4c0a
5 changed files with 672 additions and 220 deletions

View file

@ -29,6 +29,7 @@
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"autoprefixer": "^10.4.20",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",

View file

@ -0,0 +1,344 @@
/**
* Tests for the sync engine.
*
* Two layers:
* 1. Pure tests for the wire-format guards and helpers no IndexedDB
* needed, run anywhere vitest runs.
* 2. Integration tests for `applyServerChanges` against an in-memory
* Dexie db via `fake-indexeddb/auto`. These exercise the field-level
* LWW logic that Sprint 1 introduced.
*
* NOTE on running locally: the monorepo's vitest install is currently
* tangled across multiple `@vitest/*` versions in the lockfile (3.x and
* 4.x mixed). The pure tests below are written so they pass on any vitest
* 4.x; the integration block additionally needs `fake-indexeddb` (already
* a devDependency). Once vitest is realigned, `pnpm test` should pick this
* file up automatically no separate config required.
*/
import 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub the side-effect modules the Dexie hooks reach into so importing
// `database.ts` doesn't try to load funnel-tracking, automation triggers,
// or inline suggestions. The hooks themselves still run; their side
// effects are just no-ops.
vi.mock('$lib/stores/funnel-tracking', () => ({
trackFirstContent: vi.fn(),
}));
vi.mock('$lib/triggers/registry', () => ({
fire: vi.fn(),
}));
vi.mock('$lib/triggers/inline-suggest', () => ({
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
}));
import {
isValidSyncChange,
readFieldTimestamps,
applyServerChanges,
type SyncChange,
} from './sync';
import { db, FIELD_TIMESTAMPS_KEY } from './database';
// ─── Pure tests ──────────────────────────────────────────────────
describe('isValidSyncChange', () => {
const baseInsert: SyncChange = {
table: 'tasks',
id: 'task-1',
op: 'insert',
data: { title: 'hello' },
};
it('accepts a well-formed insert change', () => {
expect(isValidSyncChange(baseInsert)).toBe(true);
});
it('accepts a well-formed update change with field timestamps', () => {
const change: SyncChange = {
table: 'tasks',
id: 'task-1',
op: 'update',
fields: {
title: { value: 'updated', updatedAt: '2026-04-01T10:00:00Z' },
priority: { value: 'high', updatedAt: '2026-04-01T10:01:00Z' },
},
};
expect(isValidSyncChange(change)).toBe(true);
});
it('accepts a delete change with deletedAt', () => {
const change: SyncChange = {
table: 'tasks',
id: 'task-1',
op: 'delete',
deletedAt: '2026-04-01T10:00:00Z',
};
expect(isValidSyncChange(change)).toBe(true);
});
it('rejects null and primitives', () => {
expect(isValidSyncChange(null)).toBe(false);
expect(isValidSyncChange(undefined)).toBe(false);
expect(isValidSyncChange('not an object')).toBe(false);
expect(isValidSyncChange(42)).toBe(false);
});
it('rejects when table is missing or empty', () => {
expect(isValidSyncChange({ ...baseInsert, table: '' })).toBe(false);
expect(isValidSyncChange({ ...baseInsert, table: undefined })).toBe(false);
});
it('rejects when id is missing or empty', () => {
expect(isValidSyncChange({ ...baseInsert, id: '' })).toBe(false);
expect(isValidSyncChange({ ...baseInsert, id: undefined })).toBe(false);
});
it('rejects unknown op values', () => {
expect(isValidSyncChange({ ...baseInsert, op: 'upsert' })).toBe(false);
expect(isValidSyncChange({ ...baseInsert, op: '' })).toBe(false);
});
it('rejects malformed fields map', () => {
// Inner value is not a FieldChange object
expect(
isValidSyncChange({
...baseInsert,
op: 'update',
fields: { title: 'just a string' },
})
).toBe(false);
// updatedAt must be a string when present
expect(
isValidSyncChange({
...baseInsert,
op: 'update',
fields: { title: { value: 'x', updatedAt: 12345 } },
})
).toBe(false);
});
it('rejects when data is a primitive', () => {
expect(isValidSyncChange({ ...baseInsert, data: 'not an object' })).toBe(false);
});
it('rejects when deletedAt is not a string', () => {
expect(isValidSyncChange({ ...baseInsert, deletedAt: 123 })).toBe(false);
});
});
describe('readFieldTimestamps', () => {
it('returns the field-timestamps map when present', () => {
const ft = { title: '2026-04-01T10:00:00Z', priority: '2026-04-01T11:00:00Z' };
const record = { id: 'x', [FIELD_TIMESTAMPS_KEY]: ft };
expect(readFieldTimestamps(record)).toEqual(ft);
});
it('returns an empty map when the field is missing (legacy record)', () => {
expect(readFieldTimestamps({ id: 'x' })).toEqual({});
});
it('handles null and non-object inputs gracefully', () => {
expect(readFieldTimestamps(null)).toEqual({});
expect(readFieldTimestamps(undefined)).toEqual({});
expect(readFieldTimestamps(42)).toEqual({});
});
it('returns an empty map if __fieldTimestamps is not an object', () => {
expect(readFieldTimestamps({ id: 'x', [FIELD_TIMESTAMPS_KEY]: 'not-a-map' })).toEqual({});
});
});
// ─── Integration tests against the unified Dexie db ─────────────
describe('applyServerChanges (Dexie integration)', () => {
beforeEach(async () => {
// Wipe every sync-tracked table plus the bookkeeping ones so each
// test starts from a clean slate.
const tables = ['tasks', '_pendingChanges', '_syncMeta'];
for (const t of tables) {
try {
await db.table(t).clear();
} catch {
// Table may not exist in this Dexie version — ignore.
}
}
});
it('inserts a new record with __fieldTimestamps populated', async () => {
await applyServerChanges('todo', [
{
table: 'tasks',
id: 'task-A',
op: 'insert',
data: {
id: 'task-A',
title: 'Buy milk',
priority: 'medium',
isCompleted: false,
order: 0,
updatedAt: '2026-04-01T10:00:00Z',
},
},
]);
const stored = await db.table('tasks').get('task-A');
expect(stored).toBeDefined();
expect(stored.title).toBe('Buy milk');
const ft = readFieldTimestamps(stored);
expect(ft.title).toBe('2026-04-01T10:00:00Z');
expect(ft.priority).toBe('2026-04-01T10:00:00Z');
});
it('field-level LWW: server wins per-field when newer', async () => {
// Seed a local record via the regular Dexie API so the creating-hook
// stamps it. We can't use applyServerChanges to seed because it
// suppresses the hook; we want a *real* local record here.
await db.table('tasks').add({
id: 'task-B',
title: 'old title',
priority: 'low',
isCompleted: false,
order: 0,
});
// Server sends an update with NEWER timestamps for both fields.
await applyServerChanges('todo', [
{
table: 'tasks',
id: 'task-B',
op: 'update',
fields: {
title: { value: 'new title', updatedAt: '2099-01-01T00:00:00Z' },
priority: { value: 'high', updatedAt: '2099-01-01T00:00:00Z' },
},
},
]);
const stored = await db.table('tasks').get('task-B');
expect(stored.title).toBe('new title');
expect(stored.priority).toBe('high');
const ft = readFieldTimestamps(stored);
expect(ft.title).toBe('2099-01-01T00:00:00Z');
expect(ft.priority).toBe('2099-01-01T00:00:00Z');
});
it('field-level LWW: split outcome when one field is newer and one older', async () => {
// Seed local with field timestamps slightly in the future.
await db.table('tasks').add({
id: 'task-C',
title: 'local title',
priority: 'low',
isCompleted: false,
order: 0,
});
// Manually overwrite __fieldTimestamps so we can test the comparison
// against precise values. Use the in-progress applyingServerChanges
// flag indirectly by going through applyServerChanges with an insert
// op that overwrites field timestamps. Easier: just patch via update
// which the hook will handle by merging.
await db.table('tasks').update('task-C', {
title: 'local title v2',
priority: 'urgent',
});
// Now apply a server change where:
// - title server timestamp is OLDER → local wins
// - priority server timestamp is NEWER → server wins
await applyServerChanges('todo', [
{
table: 'tasks',
id: 'task-C',
op: 'update',
fields: {
title: { value: 'server title (loser)', updatedAt: '1970-01-01T00:00:00Z' },
priority: { value: 'medium (winner)', updatedAt: '2099-01-01T00:00:00Z' },
},
},
]);
const stored = await db.table('tasks').get('task-C');
expect(stored.title).toBe('local title v2'); // local field kept
expect(stored.priority).toBe('medium (winner)'); // server field applied
});
it('soft delete is applied when server timestamp is newer than local', async () => {
await db.table('tasks').add({
id: 'task-D',
title: 'doomed',
priority: 'low',
isCompleted: false,
order: 0,
});
await applyServerChanges('todo', [
{
table: 'tasks',
id: 'task-D',
op: 'update',
deletedAt: '2099-01-01T00:00:00Z',
},
]);
const stored = await db.table('tasks').get('task-D');
expect(stored).toBeDefined();
expect(stored.deletedAt).toBe('2099-01-01T00:00:00Z');
});
it('drops malformed entries but still applies the valid ones in the same batch', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await applyServerChanges('todo', [
// Malformed: missing id
{ table: 'tasks', op: 'insert', data: { title: 'orphan' } },
// Valid
{
table: 'tasks',
id: 'task-E',
op: 'insert',
data: {
id: 'task-E',
title: 'survives',
priority: 'low',
isCompleted: false,
order: 0,
},
},
]);
expect(warn).toHaveBeenCalledOnce();
const stored = await db.table('tasks').get('task-E');
expect(stored).toBeDefined();
expect(stored.title).toBe('survives');
} finally {
warn.mockRestore();
}
});
it('does not generate _pendingChanges entries for server-applied writes (sync loop guard)', async () => {
await applyServerChanges('todo', [
{
table: 'tasks',
id: 'task-F',
op: 'insert',
data: {
id: 'task-F',
title: 'echo me not',
priority: 'low',
isCompleted: false,
order: 0,
},
},
]);
const pendingForTaskF = await db
.table('_pendingChanges')
.filter((p: { recordId?: string }) => p.recordId === 'task-F')
.toArray();
expect(pendingForTaskF).toEqual([]);
});
});

View file

@ -25,13 +25,40 @@ import {
// ─── Types ────────────────────────────────────────────────────
/** Operations the sync protocol supports. */
export type SyncOp = 'insert' | 'update' | 'delete';
/** A single field-level change carrying its own LWW timestamp. */
export interface FieldChange {
value: unknown;
updatedAt: string;
}
/**
* One row of a changeset on the wire. Pending changes (local) and server
* changes (remote) share the same shape so the validator can be reused.
*
* Invariants the validator enforces:
* - `op === 'update'` requires `fields` (record-level `data` is ignored).
* - `op === 'insert'` requires `data`.
* - A `deletedAt` flag implies a soft delete regardless of `op`.
*/
export interface SyncChange {
table: string;
id: string;
op: SyncOp;
fields?: Record<string, FieldChange>;
data?: Record<string, unknown>;
deletedAt?: string;
}
interface PendingChange {
id?: number;
appId: string;
collection: string;
recordId: string;
op: 'insert' | 'update' | 'delete';
fields?: Record<string, { value: unknown; updatedAt: string }>;
op: SyncOp;
fields?: Record<string, FieldChange>;
data?: Record<string, unknown>;
deletedAt?: string;
createdAt: string;
@ -44,6 +71,223 @@ interface SyncMeta {
pendingCount: number;
}
// ─── Wire-format type guards ─────────────────────────────────
//
// Server payloads are untrusted: a malformed `serverChanges` entry must be
// rejected before it touches Dexie. Hand-rolled guards keep us free of a
// runtime-validation dependency while still narrowing types properly.
function isFieldChange(v: unknown): v is FieldChange {
if (!v || typeof v !== 'object') return false;
const f = v as Record<string, unknown>;
return 'value' in f && (f.updatedAt === undefined || typeof f.updatedAt === 'string');
}
function isFieldsMap(v: unknown): v is Record<string, FieldChange> {
if (!v || typeof v !== 'object') return false;
for (const value of Object.values(v as Record<string, unknown>)) {
if (!isFieldChange(value)) return false;
}
return true;
}
function isSyncOp(v: unknown): v is SyncOp {
return v === 'insert' || v === 'update' || v === 'delete';
}
/**
* Returns `true` only for objects that match the on-the-wire SyncChange
* contract well enough to apply safely. Soft errors (missing optional
* fields) are tolerated; structural errors (wrong types, missing id/table)
* are not.
*/
export function isValidSyncChange(v: unknown): v is SyncChange {
if (!v || typeof v !== 'object') return false;
const c = v as Record<string, unknown>;
if (typeof c.table !== 'string' || c.table === '') return false;
if (typeof c.id !== 'string' || c.id === '') return false;
if (!isSyncOp(c.op)) return false;
if (c.fields !== undefined && !isFieldsMap(c.fields)) return false;
if (c.data !== undefined && (typeof c.data !== 'object' || c.data === null)) return false;
if (c.deletedAt !== undefined && typeof c.deletedAt !== 'string') return false;
return true;
}
// ─── Apply Server Changes (top-level so unit tests can import directly) ──
/**
* Reads the per-field LWW timestamps off a record. Returns an empty map for
* legacy records that pre-date __fieldTimestamps so callers can fall back to
* record-level `updatedAt`.
*/
export function readFieldTimestamps(record: unknown): Record<string, string> {
if (!record || typeof record !== 'object') return {};
const ft = (record as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY];
return ft && typeof ft === 'object' ? (ft as Record<string, string>) : {};
}
/**
* Applies a batch of server changes to the local Dexie database with
* field-level Last-Write-Wins conflict resolution.
*
* Three branches based on the change op:
* - delete / deletedAt soft delete (LWW-guarded) or hard delete
* - insert upsert with LWW merge against per-field timestamps
* - update + fields field-level LWW merge using server field timestamps
*
* Hooks are suppressed via setApplyingServerChanges so applied changes do
* NOT generate new pending-changes (sync loop prevention). Malformed
* entries are dropped before any DB work happens.
*/
export async function applyServerChanges(appId: string, changes: unknown[]): Promise<void> {
// Reject malformed entries up-front so a single bad row from the server
// can never write garbage into IndexedDB. Drops are logged once and the
// good entries proceed — partial degradation beats a hard crash on a
// payload we can't fix from the client.
const validChanges: SyncChange[] = [];
let dropped = 0;
for (const c of changes) {
if (isValidSyncChange(c)) validChanges.push(c);
else dropped++;
}
if (dropped > 0) {
console.warn(
`[mana-sync] dropped ${dropped}/${changes.length} malformed server changes for app=${appId}`
);
}
if (validChanges.length === 0) return;
setApplyingServerChanges(true);
try {
// Group changes by table (server returns backend collection names)
const byTable = new Map<string, SyncChange[]>();
for (const change of validChanges) {
const unifiedTable = fromSyncName(appId, change.table);
if (!byTable.has(unifiedTable)) byTable.set(unifiedTable, []);
byTable.get(unifiedTable)!.push(change);
}
for (const [tableName, tableChanges] of byTable) {
const table = db.table(tableName);
await db.transaction('rw', table, async () => {
for (const change of tableChanges) {
const recordId = change.id;
if (change.deletedAt || change.op === 'delete') {
const existing = await table.get(recordId);
if (!existing) continue;
if (change.deletedAt) {
const localFT = readFieldTimestamps(existing);
const serverTime = change.deletedAt;
const localDeletedAtTime =
localFT.deletedAt ??
((existing as Record<string, unknown>).deletedAt as string | undefined) ??
'';
if (serverTime >= localDeletedAtTime) {
await table.update(recordId, {
deletedAt: serverTime,
updatedAt: serverTime,
[FIELD_TIMESTAMPS_KEY]: {
...localFT,
deletedAt: serverTime,
updatedAt: serverTime,
},
});
}
} else {
await table.delete(recordId);
}
} else if (change.op === 'insert') {
// Upsert. `change.data` is the canonical payload; fall back to
// the change envelope only for older flattened formats.
const existing = await table.get(recordId);
const changeData = change.data ?? (change as unknown as Record<string, unknown>);
const recordTime =
(changeData.updatedAt as string | undefined) ??
(changeData.createdAt as string | undefined) ??
new Date().toISOString();
if (!existing) {
const ft: Record<string, string> = {};
for (const key of Object.keys(changeData)) {
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
ft[key] = recordTime;
}
await table.put({
...changeData,
id: recordId,
[FIELD_TIMESTAMPS_KEY]: ft,
});
} else {
const localFT = readFieldTimestamps(existing);
const localUpdatedAt =
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
const updates: Record<string, unknown> = {};
const newFT: Record<string, string> = { ...localFT };
for (const [key, val] of Object.entries(changeData)) {
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (recordTime >= localFieldTime) {
updates[key] = val;
newFT[key] = recordTime;
}
}
if (Object.keys(updates).length > 0) {
updates[FIELD_TIMESTAMPS_KEY] = newFT;
await table.update(recordId, updates);
}
}
} else if (change.op === 'update' && change.fields) {
// Field-level LWW update — the canonical conflict-resolution path.
const existing = await table.get(recordId);
const serverFields = change.fields;
if (!existing) {
// Reconstruct from fields. Other clients only see this if the
// record was deleted locally — recreate it under the server's
// authority.
const record: Record<string, unknown> = { id: recordId };
const ft: Record<string, string> = {};
const fallback = new Date().toISOString();
for (const [key, fc] of Object.entries(serverFields)) {
record[key] = fc.value;
ft[key] = fc.updatedAt ?? fallback;
}
record[FIELD_TIMESTAMPS_KEY] = ft;
await table.put(record);
} else {
// Per-field comparison. Falls back to record-level updatedAt
// only for legacy records that pre-date __fieldTimestamps.
const localFT = readFieldTimestamps(existing);
const localUpdatedAt =
((existing as Record<string, unknown>).updatedAt as string | undefined) ?? '';
const updates: Record<string, unknown> = {};
const newFT: Record<string, string> = { ...localFT };
for (const [key, fc] of Object.entries(serverFields)) {
const serverTime = fc.updatedAt ?? '';
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (serverTime >= localFieldTime) {
updates[key] = fc.value;
newFT[key] = serverTime;
}
}
if (Object.keys(updates).length > 0) {
updates[FIELD_TIMESTAMPS_KEY] = newFT;
await table.update(recordId, updates);
}
}
}
}
});
}
} finally {
setApplyingServerChanges(false);
}
}
interface SyncChannelState {
appId: string;
tables: string[];
@ -416,150 +660,6 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
}
}
// ─── Apply Server Changes ───────────────────────────────
function readFieldTimestamps(record: unknown): Record<string, string> {
if (!record || typeof record !== 'object') return {};
const ft = (record as Record<string, unknown>)[FIELD_TIMESTAMPS_KEY];
return ft && typeof ft === 'object' ? (ft as Record<string, string>) : {};
}
async function applyServerChanges(appId: string, changes: any[]): Promise<void> {
setApplyingServerChanges(true);
try {
// Group changes by table (server returns backend collection names)
const byTable = new Map<string, any[]>();
for (const change of changes) {
const serverTable = change.table;
// Map backend collection name → unified table name
const unifiedTable = fromSyncName(appId, serverTable);
if (!byTable.has(unifiedTable)) byTable.set(unifiedTable, []);
byTable.get(unifiedTable)!.push(change);
}
for (const [tableName, tableChanges] of byTable) {
const table = db.table(tableName);
await db.transaction('rw', table, async () => {
for (const change of tableChanges) {
const recordId = change.id;
if (change.deletedAt || change.op === 'delete') {
// Soft delete (deletedAt) or hard delete
const existing = await table.get(recordId);
if (existing) {
if (change.deletedAt) {
const localFT = readFieldTimestamps(existing);
const serverTime = change.deletedAt as string;
// LWW guard: only apply if newer than local deletedAt timestamp
const localDeletedAtTime = localFT.deletedAt ?? (existing as any).deletedAt ?? '';
if (serverTime >= localDeletedAtTime) {
const newFT = {
...localFT,
deletedAt: serverTime,
updatedAt: serverTime,
};
await table.update(recordId, {
deletedAt: serverTime,
updatedAt: serverTime,
[FIELD_TIMESTAMPS_KEY]: newFT,
});
}
} else {
await table.delete(recordId);
}
}
} else if (change.op === 'insert') {
// Upsert for inserts
const existing = await table.get(recordId);
const changeData = (change.data ?? change) as Record<string, unknown>;
const recordTime =
(changeData.updatedAt as string | undefined) ??
(changeData.createdAt as string | undefined) ??
new Date().toISOString();
if (!existing) {
// Stamp every field at the record's timestamp
const ft: Record<string, string> = {};
for (const key of Object.keys(changeData)) {
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
ft[key] = recordTime;
}
await table.put({
...changeData,
id: recordId,
[FIELD_TIMESTAMPS_KEY]: ft,
});
} else {
// Existing record — merge with field-level LWW using recordTime as
// the timestamp for every incoming field.
const localFT = readFieldTimestamps(existing);
const localUpdatedAt = (existing as any).updatedAt ?? '';
const updates: Record<string, unknown> = {};
const newFT: Record<string, string> = { ...localFT };
for (const [key, val] of Object.entries(changeData)) {
if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue;
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (recordTime >= localFieldTime) {
updates[key] = val;
newFT[key] = recordTime;
}
}
if (Object.keys(updates).length > 0) {
updates[FIELD_TIMESTAMPS_KEY] = newFT;
await table.update(recordId, updates);
}
}
} else if (change.op === 'update' && change.fields) {
// Field-level LWW update
const existing = await table.get(recordId);
const serverFields = change.fields as Record<
string,
{ value: unknown; updatedAt?: string }
>;
if (!existing) {
// Record doesn't exist locally — reconstruct from fields
const record: Record<string, unknown> = { id: recordId };
const ft: Record<string, string> = {};
const fallback = new Date().toISOString();
for (const [key, fc] of Object.entries(serverFields)) {
record[key] = fc.value;
ft[key] = fc.updatedAt ?? fallback;
}
record[FIELD_TIMESTAMPS_KEY] = ft;
await table.put(record);
} else {
// Merge — compare per-field timestamps. Falls back to record-level
// updatedAt for legacy records that pre-date __fieldTimestamps.
const localFT = readFieldTimestamps(existing);
const localUpdatedAt = (existing as any).updatedAt ?? '';
const updates: Record<string, unknown> = {};
const newFT: Record<string, string> = { ...localFT };
for (const [key, fc] of Object.entries(serverFields)) {
const serverTime = fc.updatedAt ?? '';
const localFieldTime = localFT[key] ?? localUpdatedAt;
if (serverTime >= localFieldTime) {
updates[key] = fc.value;
newFT[key] = serverTime;
}
}
if (Object.keys(updates).length > 0) {
updates[FIELD_TIMESTAMPS_KEY] = newFT;
await table.update(recordId, updates);
}
}
}
}
});
}
} finally {
setApplyingServerChanges(false);
}
}
// ─── Helpers ─────────────────────────────────────────────
async function getSyncCursor(appId: string, collection: string): Promise<string> {