Skip to content
Merged
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
35 changes: 35 additions & 0 deletions packages/client/src/HybridQueryHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ export class HybridQueryHandle<T> {
// Track server data reception
private hasReceivedServerData: boolean = false;

// Cached sorted-results snapshot for synchronous external-store reads
// (useSyncExternalStore.getSnapshot). Rebuilt lazily only when the result
// set actually changes; a referentially-stable array between mutations is
// what keeps React's useSyncExternalStore from looping.
private resultsSnapshot: HybridResultItem<T>[] = [];
private resultsSnapshotDirty: boolean = true;
// True once any result emission (local or server) has occurred — lets a
// synchronous consumer derive `loading` without waiting for the first notify.
private hasEmitted: boolean = false;

// Pagination info
private _paginationInfo: PaginationInfo = { hasMore: false, cursorStatus: 'none' };
private paginationListeners: Set<(info: PaginationInfo) => void> = new Set();
Expand Down Expand Up @@ -275,12 +285,37 @@ export class HybridQueryHandle<T> {
}

private notify(): void {
// Result set changed — invalidate the cached snapshot and record emission.
this.resultsSnapshotDirty = true;
this.hasEmitted = true;
const results = this.getSortedResults();
for (const listener of this.listeners) {
listener(results);
}
}

/**
* Synchronous, referentially-stable snapshot of the current sorted result
* set for React 18 `useSyncExternalStore`. The array identity only changes
* when the result set changes (dirty flag flipped in notify()), preventing
* render loops.
*/
public getSnapshot(): HybridResultItem<T>[] {
if (this.resultsSnapshotDirty) {
this.resultsSnapshot = this.getSortedResults();
this.resultsSnapshotDirty = false;
}
return this.resultsSnapshot;
}

/**
* Synchronous metadata for deriving `loading`. `hasEmitted` is true once any
* result (local or server) has been delivered.
*/
public getSnapshotMeta(): { hasEmitted: boolean } {
return { hasEmitted: this.hasEmitted };
}

/**
* Get sorted results with _key and _score.
*/
Expand Down
23 changes: 23 additions & 0 deletions packages/client/src/HybridSearchHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export class HybridSearchHandle<T = unknown> {
/** Whether the handle has been disposed */
private disposed = false;

// Cached sorted-results snapshot for synchronous external-store reads
// (useSyncExternalStore.getSnapshot). Rebuilt lazily only when results change
// (dirty flag flipped in notifyListeners). A referentially-stable array
// between mutations is what keeps React's useSyncExternalStore from looping.
private resultsSnapshot: HybridSearchHandleResult<T>[] = [];
private resultsSnapshotDirty = true;

/** Reference to SyncEngine */
private syncEngine: SyncEngine;

Expand Down Expand Up @@ -265,6 +272,20 @@ export class HybridSearchHandle<T = unknown> {
return Array.from(this.results.values()).sort((a, b) => b.score - a.score);
}

/**
* Synchronous, referentially-stable snapshot of the current sorted results
* for React 18 `useSyncExternalStore`. The array identity only changes when
* results change (dirty flag flipped in notifyListeners), preventing render
* loops.
*/
getSnapshot(): HybridSearchHandleResult<T>[] {
if (this.resultsSnapshotDirty) {
this.resultsSnapshot = this.getResults();
this.resultsSnapshotDirty = false;
}
return this.resultsSnapshot;
}

/**
* Update the search query text.
* Sends HYBRID_SEARCH_UNSUB for the current subscriptionId, clears local results,
Expand Down Expand Up @@ -393,6 +414,8 @@ export class HybridSearchHandle<T = unknown> {
* misbehaving listener cannot silence the rest.
*/
private notifyListeners(): void {
// Results changed — invalidate the cached snapshot for getSnapshot().
this.resultsSnapshotDirty = true;
const results = this.getResults();
for (const listener of this.listeners) {
try {
Expand Down
70 changes: 62 additions & 8 deletions packages/client/src/QueryHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ export class QueryHandle<T> {
// otherwise accumulated pages beyond `limit` would be hidden.
private _paginated: boolean = false;

// Cached sorted-results snapshot for synchronous external-store reads
// (useSyncExternalStore.getSnapshot). Rebuilt lazily only when the result
// set actually changes (the dirty flag flips on every notify()). Returning a
// referentially-stable array between mutations is what keeps React's
// useSyncExternalStore from looping; building a fresh array on every read
// would.
private resultsSnapshot: (T & { _key: string })[] = [];
private resultsSnapshotDirty: boolean = true;
// True once the handle has produced ANY result emission (local or server) —
// lets a synchronous consumer derive `loading` without waiting for the first
// async notify.
private hasEmitted: boolean = false;

// Change tracking for delta notifications
private changeTracker = new ChangeTracker<T>();
private pendingChanges: ChangeEvent<T>[] = [];
Expand Down Expand Up @@ -121,14 +134,21 @@ export class QueryHandle<T> {
// [FIX]: Attempt to load local results immediately if available
// This ensures that if data is already in storage but sync hasn't happened,
// we still show something.
this.loadInitialLocalData().then((data) => {
// If we haven't received server results yet (currentResults empty),
// and we have local data OR it's just the initial load, we should notify.
// Even if data is empty, we might want to tell the subscriber "nothing here yet".
if (this.currentResults.size === 0) {
this.onResult(data, 'local');
}
});
this.loadInitialLocalData()
.then((data) => {
// If we haven't received server results yet (currentResults empty),
// and we have local data OR it's just the initial load, we should notify.
// Even if data is empty, we might want to tell the subscriber "nothing here yet".
if (this.currentResults.size === 0) {
this.onResult(data, 'local');
}
})
.catch((err) => {
// Best-effort pre-load. Some clauses (groupBy/aggregations/cursor) cannot
// be evaluated offline and reject by design — skip the local snapshot and
// let the authoritative server result settle the query.
logger.debug({ err }, 'local query pre-load skipped (not available offline)');
});

return () => {
this.listeners.delete(callback);
Expand Down Expand Up @@ -368,6 +388,10 @@ export class QueryHandle<T> {
}

private notify() {
// The result set changed — invalidate the cached snapshot so the next
// getSnapshot() rebuilds it, and record that we have emitted at least once.
this.resultsSnapshotDirty = true;
this.hasEmitted = true;
const results = this.getSortedResults();
// Snapshot the latch once per emission so every subscriber in this pass
// observes the same settled value.
Expand Down Expand Up @@ -423,6 +447,36 @@ export class QueryHandle<T> {
return results;
}

/**
* Synchronous, referentially-stable snapshot of the current sorted result
* set. Designed for React 18 `useSyncExternalStore(subscribe, getSnapshot)`:
* the returned array identity only changes when the result set actually
* changes (tracked via the dirty flag flipped in notify()), so a render that
* reads this between mutations gets the same array and React does not loop.
*
* Returns whatever the handle currently holds — including any local
* pre-loaded rows — so the FIRST render after subscribing reflects cached
* data instead of an empty array. (loadInitialLocalData runs on subscribe;
* if data is already cached it is reflected on the next getSnapshot.)
*/
public getSnapshot(): (T & { _key: string })[] {
if (this.resultsSnapshotDirty) {
this.resultsSnapshot = this.getSortedResults();
this.resultsSnapshotDirty = false;
}
return this.resultsSnapshot;
}

/**
* Synchronous metadata for deriving `loading` without an async round-trip.
* `settled` is the server-answered latch; `hasEmitted` is true once any
* result (local or server) has been delivered. A consumer can show data
* immediately when `hasEmitted` is true even before the server settles.
*/
public getSnapshotMeta(): { settled: boolean; hasEmitted: boolean } {
return { settled: this.settled, hasEmitted: this.hasEmitted };
}

public getFilter(): QueryFilter {
return this.filter;
}
Expand Down
24 changes: 24 additions & 0 deletions packages/client/src/SearchHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export class SearchHandle<T = unknown> {
/** Whether the handle has been disposed */
private disposed = false;

// Cached sorted-results snapshot for synchronous external-store reads
// (useSyncExternalStore.getSnapshot). Rebuilt lazily only when results change
// (dirty flag flipped in notifyListeners). A referentially-stable array
// between mutations is what keeps React's useSyncExternalStore from looping.
private resultsSnapshot: SearchResult<T>[] = [];
private resultsSnapshotDirty = true;

/** Reference to SyncEngine */
private syncEngine: SyncEngine;

Expand Down Expand Up @@ -154,6 +161,21 @@ export class SearchHandle<T = unknown> {
return sorted;
}

/**
* Synchronous, referentially-stable snapshot of the current sorted results
* for React 18 `useSyncExternalStore`. The array identity only changes when
* results change (dirty flag flipped in notifyListeners), preventing render
* loops. Reflects whatever results are currently cached, so the first render
* after subscribing shows them synchronously.
*/
getSnapshot(): SearchResult<T>[] {
if (this.resultsSnapshotDirty) {
this.resultsSnapshot = this.getResults();
this.resultsSnapshotDirty = false;
}
return this.resultsSnapshot;
}

/**
* Get result count.
*/
Expand Down Expand Up @@ -346,6 +368,8 @@ export class SearchHandle<T = unknown> {
* Notify all listeners of result changes.
*/
private notifyListeners(): void {
// Results changed — invalidate the cached snapshot for getSnapshot().
this.resultsSnapshotDirty = true;
const results = this.getResults();
for (const listener of this.listeners) {
try {
Expand Down
17 changes: 15 additions & 2 deletions packages/client/src/SyncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,8 +1174,21 @@ export class SyncEngine {
const localMap = this.maps.get(mapName);
if (localMap) {
if (localMap instanceof LWWMap && record) {
localMap.merge(key, record);
await this.storageAdapter.put(`${mapName}:${key}`, record);
const accepted = localMap.merge(key, record);
if (accepted) {
// Server record won LWW: memory now holds it — persist so disk matches.
await this.storageAdapter.put(`${mapName}:${key}`, record);
} else if (localMap.adoptServerEcho(key, record)) {
// Echo of our own optimistic write that the server re-stamped with a
// timestamp our clock had briefly outrun. The value is unchanged, so
// adopt the server's authoritative timestamp to converge memory, the
// Merkle tree, and disk with the server instead of re-requesting
// buckets forever (the memory/disk HLC skew this used to create).
await this.storageAdapter.put(`${mapName}:${key}`, record);
}
// else: a strictly newer local write supersedes the echo. Keep memory
// and disk as-is (both carry the local write); do NOT persist the stale
// server record — that unconditional put was the F8 skew bug.
} else if (localMap instanceof ORMap) {
if (eventType === 'OR_ADD' && orRecord) {
localMap.apply(key, orRecord);
Expand Down
55 changes: 55 additions & 0 deletions packages/client/src/__tests__/QueryManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,61 @@ describe('QueryManager', () => {
expect(results[0].value.price).toBe(150);
expect(results[1].value.price).toBe(200);
});

// F11 (TODO-501): offline parity — the local engine used to ignore
// sort/limit and silently dropped cursor/groupBy/aggregations, so offline
// results diverged from the server's.
describe('offline parity: sort / limit / unsupported clauses (F11)', () => {
beforeEach(() => {
mockStorage.getAllKeys.mockResolvedValue(['items:1', 'items:2', 'items:3']);
mockStorage.get.mockImplementation(async (key: string) => {
const data: Record<string, any> = {
'items:1': { value: { name: 'b', price: 20 } },
'items:2': { value: { name: 'a', price: 30 } },
'items:3': { value: { name: 'c', price: 10 } },
};
return data[key];
});
});

it('applies sort offline (ascending) instead of returning storage order', async () => {
const results = await queryManager.runLocalQuery('items', { sort: { price: 'asc' } });
expect(results.map((r) => r.value.price)).toEqual([10, 20, 30]);
});

it('applies sort offline (descending)', async () => {
const results = await queryManager.runLocalQuery('items', { sort: { name: 'desc' } });
expect(results.map((r) => r.value.name)).toEqual(['c', 'b', 'a']);
});

it('clamps to limit offline after sorting (server-shaped top-N)', async () => {
const results = await queryManager.runLocalQuery('items', {
sort: { price: 'asc' },
limit: 2,
});
expect(results.map((r) => r.value.price)).toEqual([10, 20]);
});

it('rejects groupBy offline instead of silently ignoring it', async () => {
await expect(queryManager.runLocalQuery('items', { groupBy: ['name'] })).rejects.toThrow(
/offline query does not support groupBy/i,
);
});

it('rejects aggregations offline', async () => {
await expect(
queryManager.runLocalQuery('items', {
aggregations: [{ func: 'sum', field: 'price' } as any],
}),
).rejects.toThrow(/aggregations/i);
});

it('rejects cursor offline', async () => {
await expect(queryManager.runLocalQuery('items', { cursor: 'abc' })).rejects.toThrow(
/cursor/i,
);
});
});
});

describe('runLocalHybridQuery', () => {
Expand Down
Loading
Loading