Local Data + Sync
Local Data + Sync
CHANGE SUMMARY
- Updated: Added the runtime schema probe that
syncFromCloudruns before any flush/merge work, referencingsrc/hooks/useAuth.jsandsrc/supabase.js.- Updated: Clarified that
SCHEMA_VERSIONnow matches Dexie v6 and is enforced bysrc/test/export-import.test.js, removing the stale warning about version lag.
Skill in use: local-data-sync — capturing Dexie schema, migrations, write/sync paths, and risks per AGENTS.md.
1. Local data model
- Dexie database:
src/db.jsinstantiatesSurfcDBwith tablesmeta,books,notes,customIdeas, andoutbox. - Entities:
books:{ id, title, author, createdAt, updatedAt, deleted }with indexes onidand timestamps; deleting a book cascades soft-delete to related notes.notes:{ id, bookId, text, page, tags[], imagePath, imageDataUrl, source, sourceId, sourceMeta, createdAt, updatedAt, deleted }plus a multiEntry index ontagsfor idea queries.customIdeas:{ id, name, description, createdAt, updatedAt, deleted }.meta: key/value store forapiKeyandlastSyncAt.outbox: queue of offline mutations{ id (auto), table, recordId, payload, createdAt }.
classDiagram class Book { id: text (PK) title: text author: text createdAt: ms epoch updatedAt: ms epoch deleted: 0/1 } class Note { id: text (PK) bookId: text | null text: string page: string tags: string[] imagePath: text | null imageDataUrl: base64 | null source: manual|image|future sourceId: string | null sourceMeta: object createdAt: ms epoch updatedAt: ms epoch deleted: 0/1 } class CustomIdea { id: text (PK) name: text description: text updatedAt: ms epoch deleted: 0/1 } class Outbox { id: auto table: text recordId: text | null payload: json createdAt: ms epoch } Note --> Book : optional bookId2. Schema evolution
- v1: base tables without
updatedAt,deleted, outbox, or provenance fields. - v2: adds
updatedAt,deleted,imagePath, and introducesoutbox; upgrade migrates existing rows. - v3: introduces
notes.source. - v4: adds
outbox.recordIdfor dedupe. - v5: adds
notes.sourceId+sourceMeta. - v6: adds Dexie
*tagsmultiEntry index. - All versions are defined inline in
src/db.js; Dexie upgrades mutate existing rows to seed new fields. SCHEMA_VERSIONnow equals6, matching Dexie v6, and Vitest enforces the guard insrc/test/export-import.test.js, so exports include provenance/tag fields without manual fixes.
3. Write path (local → cloud)
- Books/custom ideas:
saveBook/saveCustomIdeastampupdatedAtanddeleted:0;deleteBookruns a Dexie transaction to tombstone the book and related notes (src/db.js). - Notes:
saveNotepersists note text/tags/provenance locally, defaultingsource: 'manual'unless capture metadata overrides it;setNoteImagePathlater stores the Supabase storage path. - Meta:
saveApiKeyandsaveLastSynckeep user-specific metadata in Dexiemeta. - Hook integration:
useNoteForm.saveNoteFormcreates Dexie notes, uploads images viauploadImage, and immediately callscloudWrite('notes', saved)so Supabase reflects the same payload (src/hooks/useNoteForm.js). - Outbox:
useAuth.cloudWriteenqueues payloads viaenqueuewhen offline or Supabase rejects a write; entries includerecordIdto collapse retries (src/hooks/useAuth.js,src/db.js).
4. Sync path
- Schema guard (session start):
syncFromCloudchecksschemaProbed.currentbefore any writes and callsprobeCloudSchema; mismatches setsyncStatusand abort the sync so the user can runnpm run check:schema(src/hooks/useAuth.js,src/supabase.js,package.json). - Outbound (local → Supabase):
useAuth.syncFromCloudretrievesoutboxentries, callsflushOutbox(src/supabase.js), which groups mutations viacollapseOutboxItems, attempts Supabase upserts, and clears successful IDs. Tests insrc/test/outbox.test.jscover delete stickiness and ordering. - Inbound (Supabase → local): After flushing,
syncFromClouddownloads every table viafetchAllCloud(currently full-table) and feeds the payload tomergeCloudRecords. That function comparesupdated_atvs.local.updatedAt, overwriting local rows when the cloud version wins and skipping deletes for rows never stored locally (src/db.js). - Images: Post-merge,
syncFromClouditerates local notes; ifimagePathexists butimageDataUrlis missing, it downloads from Supabase Storage and patches the row viasetNoteImagePath+db.notes.update. - State refresh:
loadAllreloads filtered, soft-delete-free collections and updates React state;saveLastSyncrecords the timestamp for display. - Online/offline:
useAuthlistens towindowevents to triggersyncFromCloudupon reconnection, ensuring outbox flush before merging.
5. Conflict resolution
- Last-write-wins:
mergeCloudRecordscomparesupdated_attolocal.updatedAt; newer wins per entity. Soft-deleted cloud records (deletedtruthy) overwrite local rows, but deletion is skipped if the record never existed locally (prevents ghost resurrections). - Outbox collapse:
collapseOutboxItemssorts bycreatedAt, groups bytable + recordId, merges payload fields, and treatsdeleted:1as sticky so tombstones override earlier edits; tests cover ordering edge cases (src/test/outbox.test.js). - Source dedupe: Comments note future dedupe on
sourceId, but currentlynotes_source_id_idxis non-unique on Supabase, and Dexie stores duplicates; dedupe must occur at the app level. - Rediscovery:
useNoteActions.rediscoverIdeaspreserves custom tags while replacing canonical ones, ensuring deterministic merges when AI re-tags.
6. Export/import
buildExportpackages{ books, notes, customIdeas }alongside_syntopiconflag,schemaVersion, and ISO timestamp (src/db.js).parseImportvalidates the flag and returns arrays, throwing if the file is not a Surfc export.importMergeinserts imported rows only if they do not already exist;importReplaceclears each table then bulk inserts. Both run inside Dexie transactions and stampupdatedAtdefaults if missing.useSettingsexposes export/import through the modal UI, wiring buttons todownloadJSON, file input parsing, andconfirmMerge/confirmReplaceflows (src/hooks/useSettings.js).- There is no per-field conflict UI; merges rely solely on ID uniqueness.
7. Risks and unknowns
- One-shot schema probe:
schemaProbed.currentflips totruebefore the probe completes and never resets afterprobeCloudSchemafails, so users must reload to rerun the guard (src/hooks/useAuth.js). - Single outbox queue: Books, notes, and custom ideas share one queue without per-table pacing; a flood of note edits could block other entity types until flush completes.
- Full-table fetch:
fetchAllCloudpulls entire tables every sync; there is no incrementalupdated_atcheckpoint despitefetchSinceexisting, so large datasets could slow hydration. - No conflict UI: Last-write-wins may hide collisions between devices; users cannot inspect or resolve divergent edits beyond timestamps.
- Unknown multi-tab behavior: Dexie singleton is shared, but there is no visibility into concurrent tabs updating the same records (risking stale UI updates).
- Image consistency:
imageDataUrlis preserved during merges, but uploads happen opportunistically; missingimagePathplus no upload retry means photos can remain stranded locally without warning.