Skip to content

Commit da8c05d

Browse files
committed
refactor: collection swap and reset functionality with tests
- Improve collection-swap with better error handling and logging - Refactor use-collection-reset hook with clearAndSync functionality - Update sync-button to use new clearAndSync API - Add reset-collection database plugin improvements - Expand manager with better replication state handling - Add comprehensive tests for collection-swap and manager - Update provider and query hooks with debug logging
1 parent f324790 commit da8c05d

File tree

15 files changed

+1178
-280
lines changed

15 files changed

+1178
-280
lines changed

packages/core/src/screens/main/components/sync-button.tsx

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import * as React from 'react';
22

3-
import {
4-
ContextMenu,
5-
ContextMenuContent,
6-
ContextMenuItem,
7-
ContextMenuTrigger,
8-
} from '@wcpos/components/context-menu';
93
import {
104
DropdownMenu,
115
DropdownMenuContent,
@@ -15,33 +9,21 @@ import {
159
} from '@wcpos/components/dropdown-menu';
1610
import { Icon } from '@wcpos/components/icon';
1711
import { IconButton } from '@wcpos/components/icon-button';
18-
import { Loader } from '@wcpos/components/loader';
1912
import { Text } from '@wcpos/components/text';
2013
import { Tooltip, TooltipContent, TooltipTrigger } from '@wcpos/components/tooltip';
2114

2215
import { useT } from '../../../contexts/translations';
2316

2417
interface SyncButtonProps {
25-
sync: () => Promise<null>;
26-
clear: () => Promise<null>;
18+
sync: () => Promise<void>;
19+
clearAndSync: () => Promise<void>;
2720
active: boolean;
2821
}
2922

30-
const SyncButton = ({ sync, clear, active }: SyncButtonProps) => {
23+
const SyncButton = ({ sync, clearAndSync, active }: SyncButtonProps) => {
3124
const t = useT();
3225
const triggerRef = React.useRef(null);
3326

34-
/**
35-
*
36-
*/
37-
const handleClearAndSync = React.useCallback(async () => {
38-
await clear();
39-
// await sync(); // this sync function is going to be stale after clear
40-
}, [clear]);
41-
42-
/**
43-
*
44-
*/
4527
return (
4628
<DropdownMenu>
4729
<DropdownMenuTrigger ref={triggerRef} />
@@ -69,7 +51,7 @@ const SyncButton = ({ sync, clear, active }: SyncButtonProps) => {
6951
<Text>{t('Sync', { _tags: 'core' })}</Text>
7052
</DropdownMenuItem>
7153
<DropdownMenuSeparator />
72-
<DropdownMenuItem variant="destructive" onPress={handleClearAndSync}>
54+
<DropdownMenuItem variant="destructive" onPress={clearAndSync}>
7355
<Icon name="trash" />
7456
<Text>{t('Clear and Refresh', { _tags: 'core' })}</Text>
7557
</DropdownMenuItem>

packages/core/src/screens/main/hooks/use-collection-reset.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import * as React from 'react';
22

33
import { swapCollection, swapCollections, useQueryManager } from '@wcpos/query';
44
import type { CollectionSwapResult } from '@wcpos/query';
5+
import { getLogger } from '@wcpos/utils/logger';
56

67
import { useAppState } from '../../../contexts/app-state';
78

89
import type { CollectionKey } from './use-collection';
910

11+
const logger = getLogger(['wcpos', 'hooks', 'useCollectionReset']);
12+
1013
/**
1114
* Hook for safely resetting (clearing) a collection.
1215
*
@@ -52,5 +55,47 @@ export const useCollectionReset = (key: CollectionKey) => {
5255
return [result];
5356
}, [fastStoreDB, key, manager, storeDB]);
5457

55-
return { clear };
58+
/**
59+
* Clear the collection and then trigger a fresh sync.
60+
*
61+
* After swap, waits for queries to re-register then triggers sync
62+
* on the manager's replications (which are connected to the UI).
63+
*/
64+
const clearAndSync = React.useCallback(async (): Promise<void> => {
65+
logger.debug('clearAndSync: starting', { context: { key } });
66+
67+
const results = await clear();
68+
logger.debug('clearAndSync: swap results', {
69+
context: {
70+
results: results.map((r) => ({ success: r.success, collectionName: r.collectionName })),
71+
},
72+
});
73+
74+
// Wait for queries to re-register and replications to be created
75+
// This gives time for reset$ to propagate and queries to be recreated
76+
await new Promise((resolve) => setTimeout(resolve, 500));
77+
78+
// Trigger sync on manager's replications
79+
for (const result of results) {
80+
if (result.success && result.collectionName) {
81+
logger.debug('clearAndSync: looking for replication in manager', {
82+
context: { collectionName: result.collectionName },
83+
});
84+
85+
// Find and run replications from the manager's replicationStates
86+
manager.replicationStates.forEach((replication, endpoint) => {
87+
if ((replication as any)?.collection?.name === result.collectionName) {
88+
logger.debug('clearAndSync: triggering sync on manager replication', {
89+
context: { collectionName: result.collectionName, endpoint },
90+
});
91+
replication.run({ force: true });
92+
}
93+
});
94+
}
95+
}
96+
97+
logger.debug('clearAndSync: complete', { context: { key } });
98+
}, [clear, key, manager]);
99+
100+
return { clear, clearAndSync };
56101
};

packages/database/src/plugins/reset-collection.ts

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ import '../types.d';
1616

1717
const resetLogger = getLogger(['wcpos', 'db', 'reset']);
1818

19+
// Track removal counts for debugging
20+
const removalCounts: Record<string, number> = {};
21+
22+
// Track in-progress re-additions to prevent double execution
23+
const pendingReAdditions = new Set<string>();
24+
25+
/**
26+
* Collections currently being swapped by swapCollections().
27+
* When a collection is in this set, the reset plugin will re-add it but NOT emit on reset$.
28+
* The swapCollections function handles the reset$ emission after the swap is complete.
29+
*/
30+
export const swappingCollections = new Set<string>();
31+
1932
/**
2033
* Set of collection names that this plugin manages.
2134
* Only these collections will be auto-recreated after removal.
@@ -42,6 +55,18 @@ function isManagedCollection(collectionName: string, databaseName: string): bool
4255
const storeReset = new Subject<RxCollection>();
4356
const syncReset = new Subject<RxCollection>();
4457

58+
/**
59+
* Manually emit a reset event for a collection.
60+
* Used by swapCollections after swap is complete.
61+
*/
62+
export function emitCollectionReset(collection: RxCollection, databaseName: string): void {
63+
if (databaseName.startsWith('fast_store')) {
64+
syncReset.next(collection);
65+
} else if (databaseName.startsWith('store')) {
66+
storeReset.next(collection);
67+
}
68+
}
69+
4570
/**
4671
* Reset Collection Plugin
4772
*
@@ -97,6 +122,17 @@ export const resetCollectionPlugin: RxPlugin = {
97122
after: async (collection) => {
98123
const database = collection.database;
99124
const collectionName = collection.name;
125+
126+
// Capture stack trace immediately to debug what triggers removal
127+
const triggerStack = new Error().stack;
128+
resetLogger.debug('postCloseRxCollection triggered', {
129+
context: {
130+
collection: collectionName,
131+
database: database.name,
132+
isDestroyed: (collection as any).destroyed,
133+
trigger: triggerStack?.split('\n').slice(2, 10).join(' | '),
134+
},
135+
});
100136

101137
// Only re-add collections we manage (not FlexSearch, etc.)
102138
if (!isManagedCollection(collectionName, database.name)) {
@@ -106,10 +142,65 @@ export const resetCollectionPlugin: RxPlugin = {
106142
return;
107143
}
108144

145+
// Guard against re-entrance: if we're already re-adding this collection, skip
146+
const reAddKey = `${database.name}:${collectionName}`;
147+
if (pendingReAdditions.has(reAddKey)) {
148+
resetLogger.debug('Skipping re-addition - already in progress', {
149+
context: { collection: collectionName, database: database.name, reAddKey },
150+
});
151+
return;
152+
}
153+
154+
resetLogger.debug('Setting pending re-addition flag', {
155+
context: { reAddKey, pendingCount: pendingReAdditions.size },
156+
});
157+
pendingReAdditions.add(reAddKey);
158+
159+
// Track removal count for debugging
160+
const key = `${database.name}:${collectionName}`;
161+
removalCounts[key] = (removalCounts[key] || 0) + 1;
162+
const removalNumber = removalCounts[key];
163+
164+
// Capture stack trace to debug double-removal issues
165+
const stackTrace = new Error().stack;
166+
167+
// Check if this is a stale collection reference being closed
168+
// If a DIFFERENT collection instance already exists, this is likely a stale
169+
// reference from a previous swap, and we shouldn't re-add
170+
const existingCollection = database.collections[collectionName];
171+
const isStaleReference = existingCollection && existingCollection !== collection;
172+
const collectionAlreadyExists = !!existingCollection && !(existingCollection as any).destroyed;
173+
109174
resetLogger.debug('Re-adding collection after removal', {
110-
context: { collection: collectionName, database: database.name },
175+
context: {
176+
collection: collectionName,
177+
database: database.name,
178+
removalNumber,
179+
collectionRef: (collection as any)._instanceId || 'unknown',
180+
isStaleReference,
181+
collectionAlreadyExists,
182+
stack: stackTrace?.split('\n').slice(2, 8).join(' | '),
183+
},
111184
});
112185

186+
// If this is a stale reference being closed while a new collection exists, skip
187+
if (isStaleReference) {
188+
resetLogger.debug('Skipping re-addition - stale reference, new collection exists', {
189+
context: { collection: collectionName, database: database.name },
190+
});
191+
pendingReAdditions.delete(reAddKey);
192+
return;
193+
}
194+
195+
// If collection already exists and is not destroyed, skip re-addition
196+
if (collectionAlreadyExists) {
197+
resetLogger.debug('Collection already exists, skipping re-addition', {
198+
context: { collection: collectionName, database: database.name },
199+
});
200+
pendingReAdditions.delete(reAddKey);
201+
return;
202+
}
203+
113204
try {
114205
if (database.name.startsWith('fast_store')) {
115206
const schema = syncCollections[collectionName as keyof SyncCollections];
@@ -126,11 +217,19 @@ export const resetCollectionPlugin: RxPlugin = {
126217
}
127218

128219
const cols = await database.addCollections({ [collectionName]: schema });
129-
syncReset.next(cols[collectionName]);
130-
131-
resetLogger.debug('Sync collection re-added successfully', {
132-
context: { collection: collectionName },
133-
});
220+
221+
// Only emit on reset$ if NOT being swapped by swapCollections
222+
// (swapCollections handles emission after swap completes)
223+
if (!swappingCollections.has(collectionName)) {
224+
syncReset.next(cols[collectionName]);
225+
resetLogger.debug('Sync collection re-added and emitted reset$', {
226+
context: { collection: collectionName },
227+
});
228+
} else {
229+
resetLogger.debug('Sync collection re-added (no emit - in swap)', {
230+
context: { collection: collectionName },
231+
});
232+
}
134233
} else if (database.name.startsWith('store')) {
135234
const schema = storeCollections[collectionName as keyof StoreCollections];
136235
if (!schema) {
@@ -146,11 +245,19 @@ export const resetCollectionPlugin: RxPlugin = {
146245
}
147246

148247
const cols = await database.addCollections({ [collectionName]: schema });
149-
storeReset.next(cols[collectionName]);
150-
151-
resetLogger.debug('Store collection re-added successfully', {
152-
context: { collection: collectionName },
153-
});
248+
249+
// Only emit on reset$ if NOT being swapped by swapCollections
250+
// (swapCollections handles emission after swap completes)
251+
if (!swappingCollections.has(collectionName)) {
252+
storeReset.next(cols[collectionName]);
253+
resetLogger.debug('Store collection re-added and emitted reset$', {
254+
context: { collection: collectionName },
255+
});
256+
} else {
257+
resetLogger.debug('Store collection re-added (no emit - in swap)', {
258+
context: { collection: collectionName },
259+
});
260+
}
154261
}
155262
} catch (error: any) {
156263
resetLogger.error('Failed to re-add collection', {
@@ -163,6 +270,12 @@ export const resetCollectionPlugin: RxPlugin = {
163270
error: error.message,
164271
},
165272
});
273+
} finally {
274+
// Clear the pending flag after completion (success or failure)
275+
resetLogger.debug('Clearing pending re-addition flag', {
276+
context: { reAddKey, pendingCount: pendingReAdditions.size - 1 },
277+
});
278+
pendingReAdditions.delete(reAddKey);
166279
}
167280
},
168281
},

0 commit comments

Comments
 (0)