feat: Phase 3 — ObjectQL Data Layer#7
Conversation
…storage, sync queue, batch ops, view storage - lib/query-builder.ts: ObjectQL filter AST types, operators, serialization, projections - lib/offline-storage.ts: expo-sqlite based local record cache with schema migration - lib/sync-queue.ts: Write-ahead sync queue for offline mutations - lib/background-sync.ts: expo-background-fetch integration for periodic sync - stores/sync-store.ts: Zustand store for sync state - hooks/useNetworkStatus.ts: Network connectivity detection - hooks/useOfflineSync.ts: Auto-sync on reconnect with conflict detection - hooks/useBatchOperations.ts: Batch create/update/delete with progress - hooks/useQueryBuilder.ts: Filter tree management hook - hooks/useViewStorage.ts: Saved view CRUD via client.views API - components/query/: QueryBuilder, FilterRow, GlobalSearch UI - components/batch/: BatchActionBar, BatchProgressIndicator UI - components/views/: SaveViewDialog, ViewTabs UI - components/sync/: ConflictResolutionDialog UI - components/common/OfflineIndicator.tsx: Offline banner - Added expo-sqlite, expo-network, expo-background-fetch, expo-task-manager deps Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Implements Phase 3 of the ObjectQL data layer by adding query/filter AST utilities + UI, offline-first persistence and a mutation sync queue (with background task registration), batch operations helpers/UI, and saved view storage UI + hook.
Changes:
- Added ObjectQL filter AST types/helpers (
lib/query-builder.ts) and a query builder hook + UI components. - Added offline storage (SQLite), sync queue primitives, background fetch task registration, and Zustand sync state.
- Added batch operations hook + UI, and saved view storage hook + UI components.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
| stores/sync-store.ts | Adds Zustand state for sync status, pending count, last sync time, and conflicts. |
| package.json | Adds Expo modules for SQLite, network detection, background fetch, and task manager. |
| package-lock.json | Locks new Expo/background-fetch/network/sqlite/task-manager dependencies. |
| lib/sync-queue.ts | Implements SQLite-backed write-ahead mutation queue with status transitions. |
| lib/query-builder.ts | Defines filter AST, operator metadata, type-aware operator mapping, and serialization. |
| lib/offline-storage.ts | Implements SQLite offline cache + schema version tracking/migration markers. |
| lib/background-sync.ts | Registers background fetch task to signal pending sync work on connectivity. |
| hooks/useViewStorage.ts | Adds hook to list/create/update/delete saved views via client.views.*. |
| hooks/useQueryBuilder.ts | Adds hook for composing filter trees + projections. |
| hooks/useOfflineSync.ts | Adds foreground sync loop that drains the queue when online. |
| hooks/useNetworkStatus.ts | Adds polling-based network state hook updating global offline flag. |
| hooks/useBatchOperations.ts | Adds sequential batch create/update/delete with progress + error reporting. |
| docs/ROADMAP.md | Marks Phase 3 roadmap items as completed. |
| components/views/index.ts | Barrel exports for views components. |
| components/views/ViewTabs.tsx | Adds horizontal saved-view tab strip UI. |
| components/views/SaveViewDialog.tsx | Adds modal UI to save/edit a view name + visibility. |
| components/sync/index.ts | Barrel exports for sync components. |
| components/sync/ConflictResolutionDialog.tsx | Adds modal UI for resolving sync conflicts (keep local/server). |
| components/query/index.ts | Barrel exports for query builder components. |
| components/query/QueryBuilder.tsx | Adds query builder container UI (root logic toggle + rows). |
| components/query/GlobalSearch.tsx | Adds global search input UI. |
| components/query/FilterRow.tsx | Adds per-filter row UI (field/operator/value inputs). |
| components/common/OfflineIndicator.tsx | Adds offline/pending-changes banner with optional manual sync action. |
| components/batch/index.ts | Barrel exports for batch components. |
| components/batch/BatchProgressIndicator.tsx | Adds progress + result summary UI for batch operations. |
| components/batch/BatchActionBar.tsx | Adds bottom action bar UI for multi-select bulk actions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** Get all pending entries, oldest first */ | ||
| export function getPendingEntries(): SyncQueueEntry[] { | ||
| const db = getDatabase(); | ||
| const rows = db.getAllSync<Record<string, unknown>>( | ||
| "SELECT * FROM sync_queue WHERE status = 'pending' ORDER BY created_at ASC", |
There was a problem hiding this comment.
getPendingEntries() only selects rows with status = 'pending', but getPendingCount() counts both pending and failed, and markFailed() moves entries to failed. As written, failed entries will never be retried by the sync hook unless something calls resetEntry(). Consider including failed in getPendingEntries() (or adding explicit retry handling) so failures can be retried or surfaced predictably.
| /** Get all pending entries, oldest first */ | |
| export function getPendingEntries(): SyncQueueEntry[] { | |
| const db = getDatabase(); | |
| const rows = db.getAllSync<Record<string, unknown>>( | |
| "SELECT * FROM sync_queue WHERE status = 'pending' ORDER BY created_at ASC", | |
| /** Get all pending entries (including failed), oldest first */ | |
| export function getPendingEntries(): SyncQueueEntry[] { | |
| const db = getDatabase(); | |
| const rows = db.getAllSync<Record<string, unknown>>( | |
| "SELECT * FROM sync_queue WHERE status IN ('pending','failed') ORDER BY created_at ASC", |
| const db = getDatabase(); | ||
| db.runSync( | ||
| "UPDATE sync_queue SET status = 'in_progress', updated_at = ? WHERE id = ?", | ||
| [Date.now(), id], | ||
| ); |
There was a problem hiding this comment.
Entries marked in_progress are never selected by getPendingEntries(), and there’s no helper to recover/reset in_progress rows after an app crash/force-quit. That can permanently strand work items. Consider resetting any in_progress entries back to pending on startup (or treating in_progress as eligible after a timeout).
| // Cycle through fields for simplicity | ||
| const currentIdx = fields.findIndex((f) => f.name === filter.field); | ||
| const nextField = fields[(currentIdx + 1) % fields.length]; | ||
| if (nextField) { | ||
| onUpdate({ field: nextField.name }); |
There was a problem hiding this comment.
If fields is empty, (currentIdx + 1) % fields.length evaluates to NaN, so nextField becomes undefined. Guard against fields.length === 0 before doing the modulo/cycle logic to avoid runtime issues when metadata hasn’t loaded yet.
| break; | ||
| case "update": | ||
| if (item.recordId) { | ||
| await client.data.update(objectName, item.recordId, item.data ?? {}); | ||
| } | ||
| break; | ||
| case "delete": | ||
| if (item.recordId) { | ||
| await client.data.delete(objectName, item.recordId); | ||
| } | ||
| break; | ||
| } | ||
| result.succeeded++; |
There was a problem hiding this comment.
result.succeeded++ is executed even when an update/delete item has no recordId and therefore no server call was made. If missing recordId should be an error, move the success increment inside the branches that actually perform the operation.
| break; | |
| case "update": | |
| if (item.recordId) { | |
| await client.data.update(objectName, item.recordId, item.data ?? {}); | |
| } | |
| break; | |
| case "delete": | |
| if (item.recordId) { | |
| await client.data.delete(objectName, item.recordId); | |
| } | |
| break; | |
| } | |
| result.succeeded++; | |
| result.succeeded++; | |
| break; | |
| case "update": | |
| if (item.recordId) { | |
| await client.data.update(objectName, item.recordId, item.data ?? {}); | |
| result.succeeded++; | |
| } | |
| break; | |
| case "delete": | |
| if (item.recordId) { | |
| await client.data.delete(objectName, item.recordId); | |
| result.succeeded++; | |
| } | |
| break; | |
| } |
| isCompoundFilter, | ||
| isSimpleFilter, | ||
| OPERATOR_META, | ||
| operatorsForFieldType, |
There was a problem hiding this comment.
Several symbols imported from ~/lib/query-builder appear unused in this file (e.g., isCompoundFilter, OPERATOR_META, operatorsForFieldType, etc.). With the current ESLint config this will fail linting; remove unused imports or use them in rendering logic.
| isCompoundFilter, | |
| isSimpleFilter, | |
| OPERATOR_META, | |
| operatorsForFieldType, | |
| isSimpleFilter, |
| * Registers a periodic background task that drains the offline sync queue | ||
| * when the device regains connectivity. |
There was a problem hiding this comment.
The module header says the background task “drains the offline sync queue”, but the task currently only checks connectivity/queue length and returns NewData without processing entries. Either update this docstring to match the signal-only behavior, or implement real draining here if that’s the intent.
| * Registers a periodic background task that drains the offline sync queue | |
| * when the device regains connectivity. | |
| * Registers a periodic background task that checks connectivity and the | |
| * offline sync queue, and signals that pending work should be processed | |
| * by the foreground sync logic when the app is next active. |
|
|
||
| /** | ||
| * The task body executed in the background. | ||
| * It checks connectivity and processes pending queue entries. |
There was a problem hiding this comment.
This comment says the task “processes pending queue entries”, but no entries are actually processed/marked—only counted. Consider revising the comment to reflect the current behavior, or add the missing processing logic.
| * It checks connectivity and processes pending queue entries. | |
| * It checks connectivity and inspects pending queue entries to signal work | |
| * for the foreground sync logic. |
| const [name, setName] = useState(initialValues?.name ?? ""); | ||
| const [visibility, setVisibility] = useState<"private" | "shared">( | ||
| initialValues?.visibility ?? "private", | ||
| ); |
There was a problem hiding this comment.
name/visibility state is initialized from initialValues only on the first render. When the dialog is reopened (or initialValues changes), the inputs will retain the previous state instead of reflecting the new values. Consider syncing state in an effect keyed on visible/initialValues (or resetting state on close) so editing different views shows correct defaults.
| if (item.recordId) { | ||
| await client.data.delete(objectName, item.recordId); | ||
| } |
There was a problem hiding this comment.
In the delete case, if item.recordId is missing the code skips the API call without recording a failure. Consider treating missing recordId as an error for delete items so the batch result matches actual work performed.
| if (item.recordId) { | |
| await client.data.delete(objectName, item.recordId); | |
| } | |
| if (!item.recordId) { | |
| throw new Error("Missing recordId for delete operation"); | |
| } | |
| await client.data.delete(objectName, item.recordId); |
| import type { FieldDefinition } from "~/components/renderers/types"; | ||
| import { | ||
| type SimpleFilter, | ||
| type FilterOperator, |
There was a problem hiding this comment.
FilterOperator is imported but never used in this file. Remove the unused import to satisfy linting.
| type FilterOperator, |
Implements the complete Phase 3 data layer: advanced query support with filter AST, offline-first architecture with SQLite + sync queue, batch operations, and saved view storage.
Advanced Query Support
lib/query-builder.ts— ObjectQL filter AST types, 15 operators with field-type-aware operator mapping, serialization to wire format (['field', 'op', 'value']), compound AND/OR groups, field projectionshooks/useQueryBuilder.ts— Filter tree state management (add/update/remove/toggle/clear/serialize)components/query/—QueryBuilder,FilterRow,GlobalSearchUIOffline-First Architecture
lib/offline-storage.ts— expo-sqlite record cache with batch upsert and metadata-driven schema migrationlib/sync-queue.ts— Write-ahead queue with status tracking (pending → in_progress → completed/failed/conflict)lib/background-sync.ts— expo-background-fetch periodic sync registrationhooks/useNetworkStatus.ts— Connectivity polling, updates globalisOfflineflaghooks/useOfflineSync.ts— Auto-drains queue on reconnect, surfaces 409s as conflictscomponents/common/OfflineIndicator.tsx,components/sync/ConflictResolutionDialog.tsx— Offline banner + conflict resolution modal (keep local vs keep server)stores/sync-store.ts— Zustand store for sync stateBatch Operations
hooks/useBatchOperations.ts— Sequential batch create/update/delete with progress tracking and partial failure reportingcomponents/batch/—BatchActionBar(multi-select actions),BatchProgressIndicator(progress bar + error summary)View Storage
hooks/useViewStorage.ts— CRUD for saved views viaclient.views.*(accessed through typed wrapper since SDK doesn't expose types yet)components/views/—SaveViewDialog(name + private/shared visibility),ViewTabs(horizontal chip strip)Dependencies
expo-sqlite,expo-network,expo-background-fetch,expo-task-manager✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.