@@ -23,7 +23,11 @@ import type { BrowserCollectionCoordinatorOptions } from '../src/browser-coordin
2323 * without losing metadata. A mutating RPC may replay only through the same
2424 * known leader and term, and one envelope id cannot identify two mutation
2525 * request types. Remote subset request data must be clone-safe, and each
26- * accepted physical acquisition creates one exact acquisition lease.
26+ * accepted physical acquisition creates one exact acquisition lease. A
27+ * passive heartbeat can update a route, but only a local participant may join
28+ * that collection's leadership. Durable stream positions advance after the
29+ * adapter accepts the write, and failed release transport retains its retry
30+ * route.
2731 *
2832 * The adapter call logs, transport controls, owner callbacks, and internal-map
2933 * snapshots are focused reference ledgers. Histories vary local and follower
@@ -704,6 +708,52 @@ describe(`BrowserCollectionCoordinator`, () => {
704708 coord2 . dispose ( )
705709 } )
706710
711+ it ( `records an unrelated heartbeat without joining that collection's leadership` , async ( ) => {
712+ const adapter = createStubAdapter ( )
713+ const getStreamPosition = vi . spyOn ( adapter , `getStreamPosition` )
714+ const coordinator = createCoordinator ( adapter )
715+ coordinator . subscribe ( `todos` , ( ) => { } )
716+ await flush ( 50 )
717+
718+ injectBroadcastMessage ( `tsdb:coord:test-db` , {
719+ v : 1 ,
720+ dbName : `test-db` ,
721+ collectionId : `notes` ,
722+ senderId : `notes-owner` ,
723+ ts : Date . now ( ) ,
724+ payload : {
725+ type : `leader:heartbeat` ,
726+ term : 4 ,
727+ leaderId : `notes-owner` ,
728+ latestSeq : 3 ,
729+ latestRowVersion : 8 ,
730+ } ,
731+ } )
732+ await flush ( 0 )
733+
734+ const state = (
735+ coordinator as unknown as {
736+ collections : Map <
737+ string ,
738+ { leaderId : string | null ; latestTerm : number }
739+ >
740+ }
741+ ) . collections . get ( `notes` )
742+ expect ( {
743+ leaderId : state ?. leaderId ,
744+ latestTerm : state ?. latestTerm ,
745+ joinedLeadership : coordinator . isLeader ( `notes` ) ,
746+ streamPositionCollections : getStreamPosition . mock . calls . map (
747+ ( [ collectionId ] ) => collectionId ,
748+ ) ,
749+ } ) . toEqual ( {
750+ leaderId : `notes-owner` ,
751+ latestTerm : 4 ,
752+ joinedLeadership : false ,
753+ streamPositionCollections : [ `todos` ] ,
754+ } )
755+ } )
756+
707757 it ( `returns unique node ids` , ( ) => {
708758 const coord1 = createCoordinator ( )
709759 const coord2 = createCoordinator ( )
@@ -1253,6 +1303,61 @@ describe(`BrowserCollectionCoordinator`, () => {
12531303 follower . dispose ( )
12541304 }
12551305 } )
1306+
1307+ it ( `reuses the durable stream position after local mutation persistence fails` , async ( ) => {
1308+ const adapter = createStubAdapter ( )
1309+ const persistenceError = new Error ( `local disk full` )
1310+ const attemptedPositions : Array < { seq : number ; rowVersion : number } > = [ ]
1311+ adapter . applyCommittedTx = vi . fn ( ( _collectionId , tx ) => {
1312+ attemptedPositions . push ( { seq : tx . seq , rowVersion : tx . rowVersion } )
1313+ return attemptedPositions . length === 1
1314+ ? Promise . reject ( persistenceError )
1315+ : Promise . resolve ( )
1316+ } )
1317+ const coordinator = createCoordinator ( adapter )
1318+ coordinator . subscribe ( `todos` , ( ) => { } )
1319+ await flush ( 50 )
1320+
1321+ try {
1322+ await expect (
1323+ coordinator . requestApplyLocalMutations ( `todos` , [
1324+ {
1325+ mutationId : `failed-local-mutation` ,
1326+ type : `insert` ,
1327+ key : `failed-local-mutation` ,
1328+ value : { id : `failed-local-mutation` } ,
1329+ } ,
1330+ ] ) ,
1331+ ) . rejects . toMatchObject ( { cause : persistenceError } )
1332+
1333+ const response = await coordinator . requestApplyLocalMutations ( `todos` , [
1334+ {
1335+ mutationId : `successful-local-mutation` ,
1336+ type : `insert` ,
1337+ key : `successful-local-mutation` ,
1338+ value : { id : `successful-local-mutation` } ,
1339+ } ,
1340+ ] )
1341+
1342+ expect ( { attemptedPositions, response } ) . toEqual ( {
1343+ attemptedPositions : [
1344+ { seq : 1 , rowVersion : 1 } ,
1345+ { seq : 1 , rowVersion : 1 } ,
1346+ ] ,
1347+ response : {
1348+ type : `rpc:applyLocalMutations:res` ,
1349+ rpcId : expect . any ( String ) ,
1350+ ok : true ,
1351+ term : 1 ,
1352+ seq : 1 ,
1353+ latestRowVersion : 1 ,
1354+ acceptedMutationIds : [ `successful-local-mutation` ] ,
1355+ } ,
1356+ } )
1357+ } finally {
1358+ coordinator . dispose ( )
1359+ }
1360+ } )
12561361 } )
12571362
12581363 describe ( `RPC - applyCommittedTx` , ( ) => {
@@ -1444,6 +1549,66 @@ describe(`BrowserCollectionCoordinator`, () => {
14441549 }
14451550 } )
14461551
1552+ it ( `reuses the durable stream position after a committed transaction fails` , async ( ) => {
1553+ const adapter = createStubAdapter ( )
1554+ const persistenceError = new Error ( `disk full` )
1555+ const attemptedPositions : Array < {
1556+ term : number
1557+ seq : number
1558+ rowVersion : number
1559+ } > = [ ]
1560+ adapter . applyCommittedTx = vi . fn ( ( _collectionId , tx ) => {
1561+ attemptedPositions . push ( {
1562+ term : tx . term ,
1563+ seq : tx . seq ,
1564+ rowVersion : tx . rowVersion ,
1565+ } )
1566+ return attemptedPositions . length === 1
1567+ ? Promise . reject ( persistenceError )
1568+ : Promise . resolve ( )
1569+ } )
1570+ const coordinator = createCoordinator ( adapter )
1571+ coordinator . subscribe ( `todos` , ( ) => { } )
1572+ await flush ( 50 )
1573+
1574+ try {
1575+ await expect (
1576+ coordinator . requestApplyCommittedTx ( `todos` , {
1577+ txId : `failed-source-tx` ,
1578+ term : 0 ,
1579+ seq : 0 ,
1580+ rowVersion : 0 ,
1581+ mutations : [ ] ,
1582+ } ) ,
1583+ ) . rejects . toMatchObject ( { cause : persistenceError } )
1584+
1585+ const response = await coordinator . requestApplyCommittedTx ( `todos` , {
1586+ txId : `successful-source-tx` ,
1587+ term : 0 ,
1588+ seq : 0 ,
1589+ rowVersion : 0 ,
1590+ mutations : [ ] ,
1591+ } )
1592+
1593+ expect ( { attemptedPositions, response } ) . toEqual ( {
1594+ attemptedPositions : [
1595+ { term : 1 , seq : 1 , rowVersion : 1 } ,
1596+ { term : 1 , seq : 1 , rowVersion : 1 } ,
1597+ ] ,
1598+ response : {
1599+ type : `rpc:applyCommittedTx:res` ,
1600+ rpcId : expect . any ( String ) ,
1601+ ok : true ,
1602+ term : 1 ,
1603+ seq : 1 ,
1604+ latestRowVersion : 1 ,
1605+ } ,
1606+ } )
1607+ } finally {
1608+ coordinator . dispose ( )
1609+ }
1610+ } )
1611+
14471612 it ( `replays the successful response when only that response is lost` , async ( ) => {
14481613 const leaderAdapter = createStubAdapter ( )
14491614 const followerAdapter = createStubAdapter ( )
@@ -2402,6 +2567,70 @@ describe(`BrowserCollectionCoordinator`, () => {
24022567 }
24032568 } )
24042569
2570+ it ( `retains a follower acquisition when its release transport fails` , async ( ) => {
2571+ const leader = createCoordinator ( )
2572+ const follower = createCoordinator ( )
2573+ leader . subscribe ( `todos` , ( ) => { } )
2574+ follower . subscribe ( `todos` , ( ) => { } )
2575+ await flush ( 50 )
2576+ const owner = Object . assign (
2577+ vi . fn ( ( ) => Promise . resolve ( ) ) ,
2578+ {
2579+ unloadSubset : vi . fn ( ( ) => Promise . resolve ( ) ) ,
2580+ onError : vi . fn ( ) ,
2581+ } ,
2582+ )
2583+ const unregisterOwner = leader . registerRemoteSubsetOwner ( `todos` , owner )
2584+ const options : LoadSubsetOptions = { limit : 1 }
2585+ const followerInternals = follower as unknown as {
2586+ sendRPC : ( collectionId : string , request : unknown ) => Promise < unknown >
2587+ outboundRemoteSubsetAcquisitions : Map < string , unknown >
2588+ }
2589+
2590+ try {
2591+ await follower . requestEnsureRemoteSubset ( `todos` , options )
2592+ const sendRPC = followerInternals . sendRPC . bind ( follower )
2593+ let releaseAttempts = 0
2594+ followerInternals . sendRPC = async ( collectionId , request ) => {
2595+ if (
2596+ ( request as { type ?: string } ) . type ===
2597+ `rpc:releaseRemoteSubset:req`
2598+ ) {
2599+ releaseAttempts ++
2600+ if ( releaseAttempts === 1 ) {
2601+ return {
2602+ type : `rpc:releaseRemoteSubset:res` ,
2603+ rpcId : ( request as { rpcId : string } ) . rpcId ,
2604+ ok : false ,
2605+ error : `transient release transport failure` ,
2606+ }
2607+ }
2608+ }
2609+ return sendRPC ( collectionId , request )
2610+ }
2611+
2612+ await expect (
2613+ follower . requestReleaseRemoteSubset ( `todos` , options ) ,
2614+ ) . rejects . toThrow ( `transient release transport failure` )
2615+ expect ( {
2616+ releaseAttempts,
2617+ retained : followerInternals . outboundRemoteSubsetAcquisitions . size ,
2618+ unloads : owner . unloadSubset . mock . calls . length ,
2619+ } ) . toEqual ( { releaseAttempts : 1 , retained : 1 , unloads : 0 } )
2620+
2621+ await follower . requestReleaseRemoteSubset ( `todos` , options )
2622+ expect ( {
2623+ releaseAttempts,
2624+ retained : followerInternals . outboundRemoteSubsetAcquisitions . size ,
2625+ unloads : owner . unloadSubset . mock . calls . length ,
2626+ } ) . toEqual ( { releaseAttempts : 2 , retained : 0 , unloads : 1 } )
2627+ } finally {
2628+ unregisterOwner ( )
2629+ leader . dispose ( )
2630+ follower . dispose ( )
2631+ }
2632+ } )
2633+
24052634 it ( `keeps a Browser release tombstone when a transferred load rejects concurrently` , async ( ) => {
24062635 const coordinator = createCoordinator ( )
24072636 coordinator . subscribe ( `todos` , ( ) => { } )
0 commit comments